Compare commits
27
Commits
| 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/
|
||||
*.db
|
||||
/.claude/*
|
||||
|
||||
# Production secrets and configuration
|
||||
auth-secrets.json
|
||||
docker-compose.yml
|
||||
docker-compose.override.yml
|
||||
/WebApp/logs/*
|
||||
|
||||
+276
-223
@@ -1,123 +1,180 @@
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Diagnostics;
|
||||
using Core.Entities;
|
||||
using Google.OrTools.Sat;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using IntVar = Google.OrTools.Sat.IntVar;
|
||||
|
||||
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
|
||||
{
|
||||
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 AssignmentParameters _parameters;
|
||||
private readonly int[] _allEvents;
|
||||
private readonly double _maxSolveTimeSeconds;
|
||||
private readonly int[] _allEvents;
|
||||
private readonly int[] _allStudents;
|
||||
// how many students have picked each eventDefinition?
|
||||
// how many students have picked each event?
|
||||
private readonly int[] _eventPickCounts;
|
||||
private IList<AssignmentRequirement> _assignmentRequirements = new List<AssignmentRequirement>();
|
||||
private IList<EventDefinition> _droppedEvents = new List<EventDefinition>();
|
||||
private IList<EventDefinition> _includedEvents = new List<EventDefinition>();
|
||||
private IList<EventDefinition> _twoTeams = new List<EventDefinition>();
|
||||
private IList<AssignmentRequirement> _assignmentRequirements = [];
|
||||
private IList<EventDefinition> _droppedEvents = [];
|
||||
private IList<EventDefinition> _includedEvents = [];
|
||||
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;
|
||||
_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();
|
||||
_eventPickCounts = new int[_allEvents.Length];
|
||||
|
||||
for (var i = 0; i < _events.Count; i++)
|
||||
{
|
||||
var e = _events[i];
|
||||
|
||||
_eventPickCounts[i] = _students.Count(s => s.EventRankings.Count(er => er.EventDefinition == e) > 0);
|
||||
// Performance: Use Any() instead of Count() > 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)
|
||||
{
|
||||
_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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
_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()
|
||||
{
|
||||
Debug.WriteLine(_parameters);
|
||||
try
|
||||
{
|
||||
Debug.WriteLine(_parameters);
|
||||
|
||||
// Model.
|
||||
var model = new CpModel();
|
||||
// Create constraint programming model
|
||||
var model = new CpModel();
|
||||
|
||||
// Variables.
|
||||
var x = new BoolVar[_allEvents.Length, _allStudents.Length];
|
||||
foreach (var e in _allEvents)
|
||||
foreach (var s in _allStudents)
|
||||
x[e, s] = model.NewBoolVar($"eventAssignments[{e},{s}]");
|
||||
// Decision variables: x[e,s] = 1 if student s is assigned to event e
|
||||
var x = new BoolVar[_allEvents.Length, _allStudents.Length];
|
||||
foreach (var e in _allEvents)
|
||||
foreach (var s in _allStudents)
|
||||
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
|
||||
SetLevelOfEffort(model, x);
|
||||
AtMostOneIndividualEvent(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);
|
||||
// Configure and run solver
|
||||
Debug.WriteLine("Starting optimization");
|
||||
var solver = new CpSolver();
|
||||
solver.StringParameters = $"max_time_in_seconds:{_maxSolveTimeSeconds}";
|
||||
var cpSolverStatus = await Task.Run(() => solver.Solve(model));
|
||||
|
||||
// students should have at least one regional event
|
||||
if (_parameters.RequireRegional)
|
||||
RequireRegionalEvent(model, x);
|
||||
Debug.WriteLine($"Solver status: {cpSolverStatus}");
|
||||
|
||||
// students should have at maximum one individual event
|
||||
AtMostOneIndividualEvent(model, x);
|
||||
if (cpSolverStatus == CpSolverStatus.Infeasible)
|
||||
{
|
||||
Debug.WriteLine("Problem is infeasible - constraints cannot be satisfied");
|
||||
}
|
||||
|
||||
//EventHasInterestedStudent(model, x);
|
||||
var eventAssignmentsList = GetEventAssignments(x, solver, cpSolverStatus);
|
||||
|
||||
IndividualEventsMustBeRanked(model, x);
|
||||
var eventAssignmentSolution =
|
||||
new EventAssignmentSolution
|
||||
(
|
||||
eventAssignmentsList.ToArray(),
|
||||
cpSolverStatus.ToString(),
|
||||
assignmentThresholdsList
|
||||
);
|
||||
|
||||
OptimizeStudentEventRankings(model, x);
|
||||
|
||||
|
||||
Debug.WriteLine("Starting optimization");
|
||||
var solver = new CpSolver();
|
||||
var cpSolverStatus = await Task.Run(() => solver.Solve(model));
|
||||
|
||||
// print solver status
|
||||
Debug.WriteLine($"Solver status: {cpSolverStatus}");
|
||||
|
||||
var eventAssignmentsList = GetEventAssignments(x, solver, cpSolverStatus);
|
||||
|
||||
var eventAssignmentSolution =
|
||||
new EventAssignmentSolution
|
||||
(
|
||||
eventAssignmentsList.ToArray(),
|
||||
cpSolverStatus.ToString(),
|
||||
assignmentThresholdsList
|
||||
);
|
||||
|
||||
return eventAssignmentSolution;
|
||||
return eventAssignmentSolution;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"Error solving event assignment: {ex}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// Take the solution and map it back to the entities
|
||||
/// <summary>
|
||||
/// Extracts the solution from the solver results.
|
||||
/// </summary>
|
||||
private List<Team> GetEventAssignments(BoolVar[,] x, CpSolver solver, CpSolverStatus cpSolverStatus)
|
||||
{
|
||||
if (cpSolverStatus is not (CpSolverStatus.Optimal or CpSolverStatus.Feasible))
|
||||
@@ -140,7 +197,9 @@ namespace Core.Calculation
|
||||
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)
|
||||
{
|
||||
var maximizePicks = LinearExpr.NewBuilder();
|
||||
@@ -157,58 +216,64 @@ namespace Core.Calculation
|
||||
model.Maximize(maximizePicks);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Constraint: Each student must have 1-2 regional events.
|
||||
/// </summary>
|
||||
private void RequireRegionalEvent(CpModel model, BoolVar[,] x)
|
||||
{
|
||||
foreach (var s in _allStudents)
|
||||
{
|
||||
var regionalEvent = new List<ILiteral>();
|
||||
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();
|
||||
}
|
||||
AddStudentConstraint(model, x,
|
||||
evt => evt.RegionalEvent,
|
||||
(m, list) => m.AddLinearConstraint(LinearExpr.Sum(list), MIN_REGIONAL_EVENTS, MAX_REGIONAL_EVENTS));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Constraint: Each student can have at most one individual event.
|
||||
/// </summary>
|
||||
private void AtMostOneIndividualEvent(CpModel model, BoolVar[,] x)
|
||||
{
|
||||
foreach (var s in _allStudents)
|
||||
{
|
||||
var individualEvent = new List<ILiteral>();
|
||||
foreach (var e in _allEvents)
|
||||
{
|
||||
if (_events[e].EventFormat == EventFormat.Individual)
|
||||
individualEvent.Add(x[e, s]);
|
||||
}
|
||||
|
||||
model.AddAtMostOne(individualEvent);
|
||||
individualEvent.Clear();
|
||||
}
|
||||
AddStudentConstraint(model, x,
|
||||
evt => evt.EventFormat == EventFormat.Individual,
|
||||
(m, list) => m.AddAtMostOne(list));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Constraint: Each student must have at least one on-site activity.
|
||||
/// </summary>
|
||||
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)
|
||||
{
|
||||
var onSiteActivity = new List<ILiteral>();
|
||||
buffer.Clear();
|
||||
foreach (var e in _allEvents)
|
||||
{
|
||||
if (_events[e].OnSiteActivity)
|
||||
onSiteActivity.Add(x[e, s]);
|
||||
if (eventFilter(_events[e]))
|
||||
buffer.Add(x[e, s]);
|
||||
}
|
||||
|
||||
//if (_parameters.RequireOnSite)
|
||||
model.AddAtLeastOne(onSiteActivity);
|
||||
onSiteActivity.Clear();
|
||||
if (buffer.Count > 0)
|
||||
constraintAction(model, buffer);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Constraint: Individual events can only be assigned if the student ranked them.
|
||||
/// </summary>
|
||||
private void IndividualEventsMustBeRanked(CpModel model, BoolVar[,] x)
|
||||
{
|
||||
var prohibitVar = new List<IntVar>(1);
|
||||
|
||||
foreach (var s in _allStudents)
|
||||
{
|
||||
var student = _students[s];
|
||||
@@ -216,15 +281,20 @@ namespace Core.Calculation
|
||||
{
|
||||
var evt = _events[e];
|
||||
|
||||
var prohibitVar = new List<IntVar> { x[e, s] };
|
||||
if (evt.EventFormat == EventFormat.Individual
|
||||
&& student.EventRankings.Find(er => er.EventDefinition == evt) == null)
|
||||
{
|
||||
prohibitVar.Clear();
|
||||
prohibitVar.Add(x[e, s]);
|
||||
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)
|
||||
{
|
||||
long[] eventEffortCoefficients = _events.Select(
|
||||
@@ -240,12 +310,7 @@ namespace Core.Calculation
|
||||
effortVar[e] = x[e, s];
|
||||
}
|
||||
var student = _students[s];
|
||||
var experienceOffset = 0;
|
||||
switch (student.TsaYear)
|
||||
{
|
||||
case 1: experienceOffset = 1; break;
|
||||
default: break;
|
||||
}
|
||||
var experienceOffset = student.TsaYear == 1 ? 1 : 0;
|
||||
|
||||
var ub = _parameters.EffortUpperBound - experienceOffset;
|
||||
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)
|
||||
{
|
||||
var studentCapacity = new List<IntVar>();
|
||||
|
||||
foreach (var s in _allStudents)
|
||||
{
|
||||
var studentCapacity = new List<IntVar>();
|
||||
studentCapacity.Clear();
|
||||
foreach (var e in _allEvents)
|
||||
{
|
||||
studentCapacity.Add(x[e, s]);
|
||||
@@ -270,162 +339,146 @@ namespace Core.Calculation
|
||||
|
||||
model.AddLinearConstraint(
|
||||
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)
|
||||
{
|
||||
var assignmentThresholdsList = new List<EventAssignmentThresholds>();
|
||||
// Limit the capacity of each event
|
||||
|
||||
foreach (var e in _allEvents)
|
||||
{
|
||||
var evt = _events[e];
|
||||
var eventPickCounts = _eventPickCounts[e];
|
||||
var teamCount = CalculateTeamCount(evt, e);
|
||||
var (lb, ub) = CalculateEventBounds(evt, teamCount);
|
||||
|
||||
var evtMinTeamSize = evt.MinTeamSize;
|
||||
var evtMaxTeamSize = evt.MaxTeamSize;
|
||||
|
||||
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);
|
||||
AddEventConstraint(model, x, e, lb, ub);
|
||||
assignmentThresholdsList.Add(CreateThreshold(evt, teamCount, e));
|
||||
|
||||
Debug.WriteLine($"{evt.Name,30}\t{evt.EventFormat,-10}\t{lb} - {ub}");
|
||||
|
||||
model.Minimize(LinearExpr.Sum(eventCapacity));
|
||||
eventCapacity.Clear();
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
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 s = _students.IndexOf(includedAssignment.Student);
|
||||
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 s = _students.IndexOf(excludedAssignment.Student);
|
||||
|
||||
var prohibitVar = new List<IntVar> { x[e, s] };
|
||||
|
||||
model.AddLinearConstraint(LinearExpr.Sum(prohibitVar), 0, 0);
|
||||
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)
|
||||
{
|
||||
return
|
||||
events.Select(e =>
|
||||
return events.Select(e =>
|
||||
{
|
||||
var eventRank = eventRankings.FirstOrDefault(er => er.EventDefinition == e);
|
||||
return
|
||||
eventRank == null
|
||||
return eventRank == null
|
||||
? 0L
|
||||
// TODO: MaxRank can be calculated
|
||||
: StudentEventRanking.MaxRank - eventRank.Rank; // inverse
|
||||
: StudentEventRanking.MaxRank - eventRank.Rank; // Inverse ranking
|
||||
}).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;
|
||||
|
||||
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
|
||||
{
|
||||
private readonly IList<Student> _studentObjects;
|
||||
private readonly IList<Team> _teamObjects;
|
||||
private readonly double _maxSolveTimeSeconds;
|
||||
|
||||
private readonly int[] _students;
|
||||
private readonly int[] _teams;
|
||||
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();
|
||||
_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();
|
||||
_teams = Enumerable.Range(0, _teamObjects.Count).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)
|
||||
{
|
||||
var one = _teamObjects.IndexOf(team1);
|
||||
var two = _teamObjects.IndexOf(team2);
|
||||
_scheduleSeparateTeams.Add(Tuple.Create(one,two));
|
||||
}
|
||||
|
||||
public static TeamScheduler CreateInstance(IEnumerable<Team> teams, int numTimeSlots)
|
||||
{
|
||||
return new TeamScheduler(teams, numTimeSlots);
|
||||
if (one == -1)
|
||||
throw new ArgumentException($"Team '{team1}' not found in scheduler", nameof(team1));
|
||||
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()
|
||||
{
|
||||
// Model.
|
||||
// Create constraint programming model
|
||||
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];
|
||||
foreach (var i in _students)
|
||||
foreach (var t in _teams)
|
||||
m[i, t] = _studentObjects[i].Teams.Contains(_teamObjects[t]) ? 1 : 0;
|
||||
|
||||
// Variables.
|
||||
// x - 1 if meeting of team t takes place at time slot s, else 0
|
||||
// Decision variables:
|
||||
// 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];
|
||||
foreach (var t in _teams)
|
||||
foreach (var s in _timeSlots)
|
||||
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];
|
||||
foreach (var i in _students)
|
||||
foreach (var s in _timeSlots)
|
||||
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)
|
||||
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 s in _timeSlots)
|
||||
model.Add(
|
||||
@@ -73,35 +115,36 @@ public class TeamScheduler
|
||||
LinearExpr.Sum(_teams.Select(t => m[i, t] * x[t, s])),
|
||||
false));
|
||||
|
||||
// maximize number of times individuals meet
|
||||
// Objective: minimize the sum of y[i,s] values (minimize student conflicts)
|
||||
var indTimeSlotVars = LinearExpr.NewBuilder();
|
||||
foreach (var i in _students)
|
||||
foreach (var s in _timeSlots)
|
||||
indTimeSlotVars.Add(y[i, s]);
|
||||
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)
|
||||
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();
|
||||
solver.StringParameters = "max_time_in_seconds:2.0";
|
||||
solver.StringParameters = $"max_time_in_seconds:{_maxSolveTimeSeconds}";
|
||||
|
||||
var cpSolverStatus = solver.Solve(model);
|
||||
Debug.WriteLine($"Solver status: {cpSolverStatus}");
|
||||
var timeSlotTeams = new Team[_timeSlots.Length][];
|
||||
|
||||
if (cpSolverStatus is not (CpSolverStatus.Optimal or CpSolverStatus.Feasible))
|
||||
return new TeamSchedulerSolution(timeSlotTeams, cpSolverStatus.ToString());
|
||||
|
||||
Debug.WriteLine($"Total cost: {solver.ObjectiveValue}\n");
|
||||
return new TeamSchedulerSolution(timeSlotTeams, _studentObjects.ToArray(), cpSolverStatus.ToString());
|
||||
|
||||
// Extract solution: which teams are assigned to each time slot
|
||||
foreach (var s in _timeSlots)
|
||||
{
|
||||
var teams = (from t in _teams where solver.Value(x[t, s]) > 0 select _teamObjects[t]).ToArray();
|
||||
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;
|
||||
|
||||
/// <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(
|
||||
Team[][] timeSlots,
|
||||
Student[] students,
|
||||
string status)
|
||||
{
|
||||
public Team[][] TimeSlots { get; set; } = timeSlots;
|
||||
public string Status { get; set; } = status;
|
||||
/// <summary>
|
||||
/// 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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
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
|
||||
from s in timeSlot.SelectMany(ts => ts.Students).Distinct()
|
||||
group s by timeSlot.Where(t => t.Students.Contains(s))
|
||||
into gs
|
||||
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)
|
||||
{
|
||||
var studentsInTimeSlot = timeSlot.SelectMany(ts => ts.Students).Distinct();
|
||||
return
|
||||
(from allStudent in students
|
||||
where studentsInTimeSlot.FirstOrDefault(e => e.Equals(allStudent)) == null
|
||||
select allStudent
|
||||
).ToArray();
|
||||
|
||||
var studentsInTimeSlot = timeSlot.SelectMany(ts => ts.Students).Distinct().ToHashSet();
|
||||
return students.Where(s => !studentsInTimeSlot.Contains(s)).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)
|
||||
{
|
||||
var meetingTeams = TimeSlots.SelectMany(t => t);
|
||||
return
|
||||
student.Teams.Where(e => !meetingTeams.Contains(e)).ToArray();
|
||||
var meetingTeams = TimeSlots.SelectMany(t => t.Teams);
|
||||
return student.Teams.Where(e => !meetingTeams.Contains(e)).ToArray();
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ public class TeamScheduler_DecisionTree
|
||||
|
||||
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()
|
||||
|
||||
@@ -5,13 +5,17 @@ namespace Core.Calculation;
|
||||
public class UnassignedStudentScheduler
|
||||
{
|
||||
private readonly Student[] _students;
|
||||
private readonly Team[] _teams;
|
||||
private readonly Team[] _allTeams;
|
||||
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;
|
||||
_students = teams.SelectMany(t => t.Students).Distinct().ToArray();
|
||||
_allTeams = allTeams;
|
||||
_students = allTeams.SelectMany(t => t.Students).Distinct().ToArray();
|
||||
_timeSlots = timeslots.Select(ts => ts.Select(t => t.Clone()).ToList()).ToArray<IList<Team>>();
|
||||
}
|
||||
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
|
||||
private IEnumerable<Team> GetAvailableTeams_BiggestGroup(
|
||||
IEnumerable<Team> scheduledTeams, IEnumerable<Student> assignedStudents) =>
|
||||
_teams
|
||||
_allTeams
|
||||
.Where(t => scheduledTeams.All(st => st.Id != t.Id))
|
||||
.Select(t => t.CloneWithOmittedStudents(assignedStudents))
|
||||
.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
|
||||
private IEnumerable<Team> GetAvailableTeams_Individual(
|
||||
IEnumerable<Team> scheduledTeams, IEnumerable<Student> assignedStudents) =>
|
||||
_teams
|
||||
_allTeams
|
||||
.Where(t => scheduledTeams.All(st => st.Id != t.Id))
|
||||
.Where(t => t.Event.EventFormat == EventFormat.Individual || t.Students.Count == 1)
|
||||
.Select(t => t.CloneWithOmittedStudents(assignedStudents))
|
||||
@@ -88,14 +92,14 @@ public class UnassignedStudentScheduler
|
||||
// find any unassigned eventDefinition students can work on
|
||||
private IEnumerable<Team> GetAvailableTeams_AnyNotMeetingAlready(
|
||||
IEnumerable<Team> scheduledTeams, IEnumerable<Student> assignedStudents) =>
|
||||
_teams
|
||||
_allTeams
|
||||
.Where(t => scheduledTeams.All(st => st.Id != t.Id))
|
||||
.Select(t => t.CloneWithOmittedStudents(assignedStudents))
|
||||
.Where(t => t.Students.Count > 0);
|
||||
|
||||
private IEnumerable<Team> GetAvailableTeams_Any(
|
||||
IEnumerable<Team> scheduledTeams, IEnumerable<Student> assignedStudents) =>
|
||||
_teams
|
||||
_allTeams
|
||||
.Select(t => t.CloneWithOmittedStudents(assignedStudents))
|
||||
.Where(t => t.Students.Count > 0);
|
||||
|
||||
@@ -103,7 +107,7 @@ public class UnassignedStudentScheduler
|
||||
// find teams where several unassigned students can work together
|
||||
private IEnumerable<Team> GetAvailableTeams_LevelOfEffort(
|
||||
IEnumerable<Team> scheduledTeams, IEnumerable<Student> assignedStudents) =>
|
||||
_teams
|
||||
_allTeams
|
||||
.Where(t => scheduledTeams.All(st => st.Id != t.Id))
|
||||
.Select(t => t.CloneWithOmittedStudents(assignedStudents))
|
||||
.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})" : "")}";
|
||||
}
|
||||
|
||||
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 Core.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Data
|
||||
{
|
||||
@@ -24,64 +23,5 @@ namespace Data
|
||||
{
|
||||
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");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(1024)
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Documentation")
|
||||
.HasMaxLength(64)
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Eligibility")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("EventFormat")
|
||||
.HasColumnType("INTEGER");
|
||||
b.Property<string>("EventFormat")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("LevelOfEffort")
|
||||
.HasColumnType("INTEGER");
|
||||
@@ -69,21 +71,24 @@ namespace Data.Migrations
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("SemifinalistActivity")
|
||||
.HasMaxLength(100)
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ShortName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(40)
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Theme")
|
||||
.HasMaxLength(4096)
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name");
|
||||
b.HasIndex("EventFormat");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Events");
|
||||
});
|
||||
@@ -95,11 +100,12 @@ namespace Data.Migrations
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("FirstName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Grade")
|
||||
@@ -107,22 +113,27 @@ namespace Data.Migrations
|
||||
|
||||
b.Property<string>("LastName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NationalId")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("OfficerRole")
|
||||
.HasColumnType("INTEGER");
|
||||
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")
|
||||
@@ -130,21 +141,31 @@ namespace Data.Migrations
|
||||
|
||||
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>("EventDefinitionId")
|
||||
b.Property<int>("StudentId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("StudentId")
|
||||
b.Property<int>("EventDefinitionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Rank")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("EventDefinitionId", "StudentId");
|
||||
b.HasKey("StudentId", "EventDefinitionId");
|
||||
|
||||
b.HasIndex("EventDefinitionId");
|
||||
|
||||
b.HasIndex("Rank");
|
||||
|
||||
b.HasIndex("StudentId");
|
||||
|
||||
@@ -164,7 +185,7 @@ namespace Data.Migrations
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Identifier")
|
||||
.HasMaxLength(32)
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
@@ -173,6 +194,8 @@ namespace Data.Migrations
|
||||
|
||||
b.HasIndex("EventId");
|
||||
|
||||
b.HasIndex("EventId", "Identifier");
|
||||
|
||||
b.ToTable("Teams");
|
||||
});
|
||||
|
||||
@@ -188,7 +211,7 @@ namespace Data.Migrations
|
||||
|
||||
b.HasIndex("TeamsId");
|
||||
|
||||
b.ToTable("StudentTeam");
|
||||
b.ToTable("TeamStudents", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Core.Entities.StudentEventRanking", b =>
|
||||
@@ -214,12 +237,13 @@ namespace Data.Migrations
|
||||
{
|
||||
b.HasOne("Core.Entities.Student", "Captain")
|
||||
.WithMany()
|
||||
.HasForeignKey("CaptainId");
|
||||
.HasForeignKey("CaptainId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("Core.Entities.EventDefinition", "Event")
|
||||
.WithMany()
|
||||
.HasForeignKey("EventId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
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 Tests.Parsers;
|
||||
using Core.Entities;
|
||||
using Tests.Builders;
|
||||
using Tests.Fixtures;
|
||||
|
||||
namespace Tests.Calculation;
|
||||
|
||||
[TestFixture]
|
||||
public class DataProcessingTests
|
||||
{
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
BuilderExtensions.ResetAllBuilders();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetEventStudentRankingsTest()
|
||||
{
|
||||
var events = TestEntityHandler.GetEvents();
|
||||
var students = TestEntityHandler.GetStudents(events);
|
||||
var rankings = TestEntityHandler.GetStudentEventRankings(students, events);
|
||||
// Create test data with explicit rankings
|
||||
var flight = EventDefinitionBuilder.Individual("Flight").Build();
|
||||
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.Student.FirstName}: {ranking.Rank}");
|
||||
|
||||
@@ -1,24 +1,175 @@
|
||||
using Core.Calculation;
|
||||
using Core.Entities;
|
||||
using Core.Parsers;
|
||||
using Tests.Parsers;
|
||||
using Tests.Builders;
|
||||
using Tests.Fixtures;
|
||||
using EventAssignment = Core.Calculation.EventAssignment;
|
||||
|
||||
namespace Tests.Calculation;
|
||||
|
||||
[TestFixture]
|
||||
public class EventAssignmentTests
|
||||
{
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
BuilderExtensions.ResetAllBuilders();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SolutionTest()
|
||||
public async Task SolutionTest()
|
||||
{
|
||||
var events = TestEntityHandler.GetEvents();
|
||||
var students = TestEntityHandler.GetStudents(events);
|
||||
// Create custom test data with sufficient rankings for EventAssignment
|
||||
// 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 solution = eventAssignment.Solve().Result;
|
||||
var flight = EventDefinitionBuilder.Individual("Flight").AsRegionalEvent().Build();
|
||||
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");
|
||||
//teamWriter.Write();
|
||||
var robotics = EventDefinitionBuilder.Team("Robotics", 2, 5).AsRegionalEvent().AsOnSite().Build();
|
||||
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.Entities;
|
||||
using Tests.Parsers;
|
||||
using Tests.Builders;
|
||||
using Tests.Fixtures;
|
||||
|
||||
namespace Tests.Calculation;
|
||||
|
||||
[TestFixture]
|
||||
public class TeamSchedulerTest
|
||||
{
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
BuilderExtensions.ResetAllBuilders();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Prototype_Test()
|
||||
{
|
||||
@@ -17,73 +24,83 @@ public class TeamSchedulerTest
|
||||
[Test]
|
||||
public void SolutionTest()
|
||||
{
|
||||
var events = TestEntityHandler.GetEvents();
|
||||
var students = TestEntityHandler.GetStudents(events);
|
||||
// Create test data with regional team events for TeamScheduler
|
||||
var robotics = EventDefinitionBuilder.Team("Robotics", 2, 5).AsRegionalEvent().Build();
|
||||
var biotech = EventDefinitionBuilder.Team("Biotechnology", 2, 6).AsRegionalEvent().Build();
|
||||
var webDesign = EventDefinitionBuilder.Team("Website Design", 3, 6).AsRegionalEvent().Build();
|
||||
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();
|
||||
|
||||
var allTeams = TestEntityHandler.GetTeams(events, students);
|
||||
var teams = allTeams;
|
||||
var events = new[] { robotics, biotech, webDesign, videoGame, engineering, cybersecurity };
|
||||
|
||||
teams =
|
||||
(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();
|
||||
// Create students
|
||||
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();
|
||||
|
||||
//var eventAssignment = new EventAssignment(events, students);
|
||||
//var teams = eventAssignment.Solve();
|
||||
var students = new[] { alice, bob, carol, david, eve, frank, grace, henry };
|
||||
|
||||
TeamSchedulerSolution solution;
|
||||
if (true)
|
||||
// Create teams with student assignments
|
||||
var allTeams = new[]
|
||||
{
|
||||
var teamScheduler = TeamScheduler.CreateInstance(teams, 3);
|
||||
solution = teamScheduler.Solve();
|
||||
}
|
||||
else
|
||||
{
|
||||
var teamScheduler = new TeamScheduler_DecisionTree(teams, 3);
|
||||
solution = teamScheduler.Solve();
|
||||
}
|
||||
TeamBuilder.Default().ForEvent(robotics).WithStudentsAndCaptain(alice, bob, carol).WithIdentifier("Robotics A").Build(),
|
||||
TeamBuilder.Default().ForEvent(biotech).WithStudentsAndCaptain(david, eve, frank).WithIdentifier("Biotech A").Build(),
|
||||
TeamBuilder.Default().ForEvent(webDesign).WithStudentsAndCaptain(grace, henry, alice).WithIdentifier("WebDesign A").Build(),
|
||||
TeamBuilder.Default().ForEvent(videoGame).WithStudentsAndCaptain(bob, david).WithIdentifier("VideoGame A").Build(),
|
||||
TeamBuilder.Default().ForEvent(engineering).WithStudentsAndCaptain(carol, eve, frank).WithIdentifier("Engineering A").Build(),
|
||||
TeamBuilder.Default().ForEvent(cybersecurity).WithStudentsAndCaptain(grace, henry).WithIdentifier("Cybersecurity A").Build()
|
||||
};
|
||||
|
||||
//solution = new UnassignedStudentScheduler(allTeams, solution.TimeSlots).ScheduleStrategy(UnassignedScheduleStrategy.BiggestGroup);
|
||||
//solution = new UnassignedStudentScheduler(allTeams, solution.TimeSlots).ScheduleStrategy(UnassignedScheduleStrategy.IndividualEvents);
|
||||
// Filter to only regional team events (matching original test logic)
|
||||
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;
|
||||
foreach (var slot in solution.TimeSlots)
|
||||
{
|
||||
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));
|
||||
Console.WriteLine($"\t{team.Event.Name}");
|
||||
Console.WriteLine($"\t\t{names}");
|
||||
}
|
||||
|
||||
var overlaps = TeamSchedulerSolution.GetStudentTeamOverlaps(slot).ToList();
|
||||
var overlaps = TeamSchedulerSolution.GetStudentTeamOverlaps(slot.Teams).ToList();
|
||||
|
||||
if (overlaps.Any())
|
||||
{
|
||||
Console.WriteLine("\toverlaps");
|
||||
foreach (var overlap in overlaps)
|
||||
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())
|
||||
Console.WriteLine("\tunassigned");
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,11 +14,11 @@
|
||||
|
||||
<div class="bluewhite p-5">
|
||||
@{
|
||||
List<Tuple<Student, IEnumerable<Team>>> overlaps;
|
||||
List<(Student student, IEnumerable<Team> teams)> overlaps;
|
||||
}
|
||||
@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 fullTeams = timeslot.Where(t => !partialTeams.Contains(t));
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@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;
|
||||
@@ -36,7 +36,7 @@
|
||||
{
|
||||
first = false;
|
||||
}
|
||||
@if (overlaps.Any(t => t.Item1 == student))
|
||||
@if (overlaps.Any(t => t.student == student))
|
||||
{
|
||||
<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;
|
||||
|
||||
public int[] UserId
|
||||
public int[] ScheduledTeams
|
||||
{
|
||||
get => _scheduledTeams ?? [];
|
||||
set
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<Routes @rendermode="InteractiveServer" />
|
||||
<AppErrorBoundary>
|
||||
<Routes @rendermode="InteractiveServer" />
|
||||
</AppErrorBoundary>
|
||||
|
||||
<script src="_framework/blazor.web.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();
|
||||
");
|
||||
}
|
||||
}
|
||||
+7
-7
@@ -1,4 +1,5 @@
|
||||
@page "/events/create"
|
||||
@attribute [Authorize]
|
||||
@inject AppDbContext context
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
@@ -10,6 +11,7 @@
|
||||
|
||||
|
||||
<EditForm Model="EventDefinition" OnValidSubmit="OnValidSubmit" Enhance>
|
||||
<AntiforgeryToken />
|
||||
<DataAnnotationsValidator />
|
||||
<MudGrid>
|
||||
<MudItem xs="12" sm="7">
|
||||
@@ -17,17 +19,15 @@
|
||||
<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>
|
||||
|
||||
<label for="@EventDefinition.EventFormat" class="form-label">Format:</label>
|
||||
<MudRadioGroup T="EventFormat" @bind-Value="@EventDefinition.EventFormat">
|
||||
@* <MudRadio T="EventFormat" Value="EventFormat.Team">Team</MudRadio>
|
||||
<MudRadio T="EventFormat" Value="EventFormat.Individual">Individual</MudRadio> *@
|
||||
<MudText Typo="Typo.subtitle2" Class="mt-4 mb-2">Format</MudText>
|
||||
<MudRadioGroup T="EventFormat" @bind-Value="@EventDefinition.EventFormat" For="@(() => EventDefinition.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>
|
||||
<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="Documentation" @bind-Value="EventDefinition.Documentation" For="@(() => EventDefinition.Documentation)"></MudTextField>
|
||||
<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"
|
||||
@attribute [Authorize]
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@inject AppDbContext context
|
||||
@inject NavigationManager NavigationManager
|
||||
@@ -11,6 +12,7 @@
|
||||
|
||||
|
||||
<EditForm Model="EventDefinition" OnValidSubmit="OnValidSubmit" Enhance>
|
||||
<AntiforgeryToken />
|
||||
<DataAnnotationsValidator />
|
||||
<MudGrid>
|
||||
<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="Short Name" @bind-Value="EventDefinition.ShortName" For="@(() => EventDefinition.ShortName)"></MudTextField>
|
||||
|
||||
<label for="@EventDefinition.EventFormat" class="form-label">Format:</label>
|
||||
<MudRadioGroup T="EventFormat" @bind-Value="@EventDefinition.EventFormat">
|
||||
@* <MudRadio T="EventFormat" Value="EventFormat.Team">Team</MudRadio>
|
||||
<MudRadio T="EventFormat" Value="EventFormat.Individual">Individual</MudRadio> *@
|
||||
<MudText Typo="Typo.subtitle2" Class="mt-4 mb-2">Format</MudText>
|
||||
<MudRadioGroup T="EventFormat" @bind-Value="@EventDefinition.EventFormat" For="@(() => EventDefinition.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>
|
||||
<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="Documentation" @bind-Value="EventDefinition.Documentation" For="@(() => EventDefinition.Documentation)"></MudTextField>
|
||||
<MudNumericField T="int?" Label="Level of Effort" @bind-Value="EventDefinition.LevelOfEffort" For="@(() => EventDefinition.LevelOfEffort)"></MudNumericField>
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
@page "/events"
|
||||
@attribute [Authorize]
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using WebApp.Models
|
||||
@inject AppDbContext Context
|
||||
@@ -83,6 +84,6 @@
|
||||
|
||||
//_isRowBlocked = false;
|
||||
StateHasChanged();
|
||||
_dataGrid.ReloadServerData();
|
||||
await _dataGrid.ReloadServerData();
|
||||
}
|
||||
}
|
||||
+1
@@ -1,4 +1,5 @@
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@attribute [Authorize]
|
||||
@page "/events/printout"
|
||||
@inject IConfiguration Configuration
|
||||
@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"
|
||||
@attribute [Authorize]
|
||||
@inject AppDbContext Context
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
@@ -10,6 +11,7 @@
|
||||
|
||||
|
||||
<EditForm Model="Student" OnValidSubmit="OnValidSubmit" Enhance>
|
||||
<AntiforgeryToken />
|
||||
<DataAnnotationsValidator />
|
||||
<MudGrid>
|
||||
<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"
|
||||
@attribute [Authorize]
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@inject AppDbContext Context
|
||||
@inject NavigationManager NavigationManager
|
||||
@@ -19,6 +20,7 @@ else
|
||||
/* https://www.mudblazor.com/components/form */
|
||||
/* https://medium.com/@husainalbar/applying-mudblazor-for-crud-operations-in-our-blazor-project-a343037a52ef */
|
||||
<EditForm method="post" Model="Student" OnValidSubmit="UpdateStudent" FormName="edit" Enhance>
|
||||
<AntiforgeryToken />
|
||||
<DataAnnotationsValidator/>
|
||||
<MudGrid>
|
||||
<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
|
||||
@page "/students/event-ranking"
|
||||
@inject AppDbContext Context
|
||||
@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 WebApp.Models
|
||||
@page "/students/event-ranking-edit/{StudentId:int}"
|
||||
@inject AppDbContext Context
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
@page "/students"
|
||||
@attribute [Authorize]
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using WebApp.Models
|
||||
@inject AppDbContext Context
|
||||
@@ -82,6 +83,6 @@
|
||||
|
||||
//_isRowBlocked = false;
|
||||
StateHasChanged();
|
||||
_dataGrid.ReloadServerData();
|
||||
await _dataGrid.ReloadServerData();
|
||||
}
|
||||
}
|
||||
+5
-4
@@ -1,4 +1,5 @@
|
||||
@page "/teams/assignment"
|
||||
@attribute [Authorize]
|
||||
@using Core.Calculation
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using WebApp.Models
|
||||
@@ -204,14 +205,14 @@
|
||||
}
|
||||
@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"
|
||||
OnClick="() => RequireEvent(e, context.Student, Requirement.Include)"></MudIconButton>
|
||||
</MudTooltip>
|
||||
}
|
||||
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"
|
||||
OnClick="() => RemoveRequireEvent(e, context.Student, Requirement.Include)"></MudIconButton>
|
||||
</MudTooltip>
|
||||
@@ -219,14 +220,14 @@
|
||||
|
||||
@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"
|
||||
OnClick="() => RequireEvent(e, context.Student, Requirement.Exclude)"></MudIconButton>
|
||||
</MudTooltip>
|
||||
}
|
||||
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"
|
||||
OnClick="() => RemoveRequireEvent(e, context.Student, Requirement.Exclude)"></MudIconButton>
|
||||
</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");
|
||||
}
|
||||
}
|
||||
+19
-26
@@ -1,4 +1,5 @@
|
||||
@page "/teams/edit"
|
||||
@attribute [Authorize]
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@inject AppDbContext Context
|
||||
@inject NavigationManager NavigationManager
|
||||
@@ -15,32 +16,20 @@
|
||||
else
|
||||
{
|
||||
<EditForm method="post" Model="Team" OnValidSubmit="UpdateTeam" FormName="edit" Enhance>
|
||||
<AntiforgeryToken />
|
||||
<DataAnnotationsValidator/>
|
||||
<MudGrid>
|
||||
<MudItem xs="12" sm="7">
|
||||
<MudPaper Class="pa-4">
|
||||
<MudSelect
|
||||
T="Student"
|
||||
MultiSelection="true"
|
||||
@bind-SelectedValues="@_selectedStudents"
|
||||
ToStringFunc="e => e.Name"
|
||||
Label="Students">
|
||||
|
||||
@foreach (var student in _students.OrderBy(e => e.FirstName))
|
||||
{
|
||||
<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>
|
||||
<StudentToggleSelector Students="@_students"
|
||||
@bind-SelectedStudents="_selectedStudents"
|
||||
Title="Students"
|
||||
ShowFullName="true" />
|
||||
<MudStack Class="mt-4">
|
||||
<TeamCaptainSelector Students="@_selectedStudents"
|
||||
@bind-SelectedCaptain="Team.Captain"
|
||||
Title="Captain" />
|
||||
</MudStack>
|
||||
<MudTextField T="string?" Label="Identifier" @bind-Value="Team.Identifier" For="@(() => Team.Identifier)" Required="false" Clearable="true"></MudTextField>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
@@ -56,7 +45,7 @@ else
|
||||
[SupplyParameterFromForm]
|
||||
private Team? Team { get; set; }
|
||||
|
||||
private IEnumerable<Student> _selectedStudents = new HashSet<Student>();
|
||||
private IEnumerable<Student> _selectedStudents = [];
|
||||
private List<Student> _students = [];
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
@@ -66,10 +55,7 @@ else
|
||||
.Include(e => e.Students)
|
||||
.FirstOrDefaultAsync(m => m.Id == Id);
|
||||
_students = await Context.Students.ToListAsync();
|
||||
foreach (var s in Team.Students)
|
||||
{
|
||||
((HashSet<Student>)_selectedStudents).Add(s);
|
||||
}
|
||||
_selectedStudents = Team.Students.ToList();
|
||||
|
||||
if (Team is null)
|
||||
{
|
||||
@@ -96,6 +82,13 @@ else
|
||||
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
|
||||
{
|
||||
await Context.SaveChangesAsync();
|
||||
+3
-2
@@ -1,6 +1,7 @@
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@page "/teams/handout"
|
||||
@attribute [Authorize]
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using WebApp.Models
|
||||
@page "/teams/handout"
|
||||
@inject IConfiguration Configuration
|
||||
@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 IDialogService DialogService
|
||||
@inject ISnackbar Snackbar
|
||||
@@ -33,7 +34,6 @@
|
||||
<TemplateColumn>
|
||||
<CellTemplate>
|
||||
<CrudActions
|
||||
DetailsHref="@($"/teams/details?id={context.Item!.Id}")"
|
||||
EditHref="@($"/teams/edit?id={context.Item!.Id}")"
|
||||
DeleteOnClick="() => DeleteTeam(context.Item!)">
|
||||
</CrudActions>
|
||||
@@ -82,6 +82,20 @@
|
||||
|
||||
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!);
|
||||
await Context.SaveChangesAsync();
|
||||
Snackbar.Add($"Delete event: Delete of Team {team}", Severity.Info);
|
||||
@@ -89,6 +103,6 @@
|
||||
|
||||
//_isRowBlocked = false;
|
||||
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
|
||||
@page "/teams/printout"
|
||||
@inject IConfiguration Configuration
|
||||
@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 "/"
|
||||
@attribute [Authorize]
|
||||
@inject IConfiguration Configuration
|
||||
|
||||
<PageTitle>Home</PageTitle>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
@page "/import"
|
||||
@attribute [Authorize(Roles = AuthRoles.Administrator)]
|
||||
@using Core.Parsers
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using WebApp.Authentication
|
||||
@inject AppDbContext Context
|
||||
|
||||
@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">
|
||||
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
|
||||
<FocusOnNavigate RouteData="routeData" Selector="h1" />
|
||||
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
|
||||
<NotAuthorized>
|
||||
@{
|
||||
navigationManager.NavigateTo("/login", true);
|
||||
}
|
||||
</NotAuthorized>
|
||||
</AuthorizeRouteView>
|
||||
<FocusOnNavigate RouteData="@routeData" Selector="h1"/>
|
||||
</Found>
|
||||
<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">
|
||||
<MudIconButton Icon="@Icons.Material.Outlined.Delete"
|
||||
Color="Color.Error"
|
||||
OnClick="() => DeleteOnClick()"/>
|
||||
OnClick="HandleDeleteClick"/>
|
||||
</MudTooltip>
|
||||
}
|
||||
</MudButtonGroup>
|
||||
@@ -25,5 +25,13 @@
|
||||
|
||||
[Parameter] public string? DetailsHref { 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;
|
||||
|
||||
namespace WebApp.Components.Layout
|
||||
namespace WebApp.Components.Shared.Layout
|
||||
{
|
||||
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">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" Edge="Edge.Start" OnClick="@((e) => DrawerToggle())" />
|
||||
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>
|
||||
<MudDrawer @bind-Open="@_drawerOpen" Class="no-print">
|
||||
<NavMenu/>
|
||||
@@ -1,5 +1,7 @@
|
||||
@using System.Net.Http
|
||||
@using System.Net.Http.Json
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@@ -8,6 +10,18 @@
|
||||
@using Microsoft.JSInterop
|
||||
@using WebApp
|
||||
@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 Core.Entities
|
||||
@using Data
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+115
-2
@@ -1,12 +1,42 @@
|
||||
using Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MudBlazor.Services;
|
||||
using Serilog;
|
||||
using WebApp;
|
||||
using WebApp.Authentication;
|
||||
using WebApp.Components;
|
||||
|
||||
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.
|
||||
builder.Services.AddControllersWithViews();
|
||||
builder.Services.AddRazorComponents()
|
||||
.AddInteractiveServerComponents();
|
||||
|
||||
@@ -22,11 +52,72 @@ builder.Services.AddDatabaseDeveloperPageExceptionFilter();
|
||||
|
||||
builder.Services.AddScoped<ClipboardService>();
|
||||
|
||||
builder.Services.AddScoped<StateContainer>(); // Server- side
|
||||
builder.Services.AddSingleton<StateContainer>();//Client-side
|
||||
// State container for maintaining state per user connection (Blazor Server)
|
||||
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();
|
||||
|
||||
// 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.
|
||||
if (!app.Environment.IsDevelopment())
|
||||
{
|
||||
@@ -37,6 +128,10 @@ if (!app.Environment.IsDevelopment())
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
app.UseRouting();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.UseStaticFiles();
|
||||
app.UseAntiforgery();
|
||||
@@ -44,4 +139,22 @@ app.UseAntiforgery();
|
||||
app.MapRazorComponents<App>()
|
||||
.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();
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
||||
<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.Diagnostics.EntityFrameworkCore" Version="9.0.8" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.8">
|
||||
@@ -25,7 +27,10 @@
|
||||
</PackageReference>
|
||||
<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="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>
|
||||
|
||||
@@ -4,5 +4,15 @@
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"Serilog": {
|
||||
"MinimumLevel": {
|
||||
"Default": "Debug",
|
||||
"Override": {
|
||||
"Microsoft": "Warning",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"System": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,16 @@
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"Serilog": {
|
||||
"MinimumLevel": {
|
||||
"Default": "Information",
|
||||
"Override": {
|
||||
"Microsoft": "Warning",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"System": "Warning"
|
||||
}
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"ConnectionStrings": {
|
||||
"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