Compare commits

..
27 Commits
Author SHA1 Message Date
poprhythm 826eac1372 Database improvement
1.  Separate configuration files
2.  Remove commented code
3.  Improve entity configurations (constraints, indexes, relationships)
2025-12-04 08:09:01 -05:00
poprhythm 2d5d075879 Feature-based folder structure
1. Created feature-based folder structure - Components now organized by domain feature
  2. Moved all components - 20+ files moved to new locations
  3. Updated _Imports.razor - Added all new namespace paths for global component access
  4. Updated CustomThemes.cs namespace - Changed from WebApp.Components.Layout to WebApp.Components.Shared.Layout
  5. Removed old using directives - Cleaned up Login.razor and Routes.razor
  6. Removed empty directories - Cleaned up old folder structure
2025-12-03 22:04:23 -05:00
poprhythm bd04483bed Database migrations are now checked in production. 2025-12-03 15:20:43 -05:00
poprhythm 6fc1ec1192 Remove logs 2025-12-03 14:15:19 -05:00
poprhythm a0313687da 1. Fixed Misleading Property Names
- File: WebApp/ChapterSettings.cs
  - Change: Renamed StateContainer.UserId to ScheduledTeams
  - Impact: Property name now accurately reflects what it stores

  2.  Added Structured Logging with Serilog

  - Packages Added:
    - Serilog.AspNetCore
    - Serilog.Sinks.Console
    - Serilog.Sinks.File
  - Files Modified:
    - Program.cs - Added Serilog configuration with console and file logging
    - appsettings.json - Added Serilog minimum log levels
    - appsettings.Development.json - Added Debug level logging for development
  - Benefits:
    - Structured log output for better parsing/analysis
    - Automatic file rotation (daily, 30 days retention)
    - Logs stored in logs/webapp-.txt
    - Better formatted console output

  3.  Added Global Error Handling

  - File Created: WebApp/Components/Shared/AppErrorBoundary.razor
  - File Modified: WebApp/Components/App.razor
  - Features:
    - Catches unhandled exceptions throughout the app
    - Shows detailed error info in Development environment
    - Shows user-friendly message in Production
    - Logs errors automatically
    - Provides "Return to Home" button

  4.  Enhanced Input Validation

  - File Modified: WebApp/Components/Login.razor
  - Validations Added:
    - Email: Required, valid email format, max 100 chars, regex validation
    - Password: Required, min 8 chars, max 100 chars
  - Benefits:
    - Client-side validation before submission
    - Clear error messages for users
    - Prevents invalid data submission
2025-12-03 14:10:08 -05:00
poprhythm 54875e970c Add more secure form handling 2025-12-02 22:36:41 -05:00
poprhythm 1c38003027 Introduce unit testing on calculation objects 2025-12-02 20:06:05 -05:00
poprhythm 8039e751d8 Refactor/cleanup the EventAssignemnt 2025-12-01 21:38:14 -05:00
poprhythm feaaf76f46 Add an empty layout for the login page.
Move some of the shared components
2025-12-01 20:54:34 -05:00
poprhythm 3461f94854 Refactor TeamScheduler, easier to read and maintain 2025-12-01 06:58:56 -05:00
poprhythm 69dd517d73 Fix a bunch of warnings 2025-12-01 00:11:52 -05:00
poprhythm bfaebfbb13 Fix up CRUD pages a bit. 2025-12-01 00:03:25 -05:00
poprhythm a2857d56e0 Auth configuration in docker 2025-11-30 23:55:34 -05:00
poprhythm e5bf3692f6 Authentication implementation 2025-11-30 23:48:58 -05:00
poprhythm 382fffe1d4 Refactor the Meeting Scheduler 2025-11-30 23:03:52 -05:00
poprhythm 46843fea0f Removed extra title for absent student. 2025-11-24 15:04:19 -05:00
poprhythm 688dfd6d0f Add text box selector for students 2025-11-24 08:21:23 -05:00
poprhythm d8f2a3cf52 Improve team identifier handling 2025-11-23 13:30:24 -05:00
poprhythm 1aaebe5dce Handle absent students in meetin schedule 2025-11-23 11:39:16 -05:00
poprhythm 72da28992f Team scheduler now considers all available students 2025-11-23 11:05:29 -05:00
poprhythm 87be3e9c68 Refinements to auth 2025-11-23 11:04:20 -05:00
poprhythm d188d4fbd1 Add simple auth 2025-11-11 14:41:51 -05:00
poprhythm 307c6e103f Update mudblazor 2025-11-11 14:01:14 -05:00
poprhythm 0228abab01 Fix references for TeamSchedulerTest 2025-11-11 14:00:39 -05:00
poprhythm b7fa7fcbe1 Add recomendations for meeting scheduler 2025-10-31 13:29:43 -04:00
poprhythm cf9949876d Add a TimeSlot object, refactor 2025-10-30 09:35:41 -04:00
poprhythm 967aa567e8 Progress on schedule page formatting 2025-10-27 14:44:11 -04:00
90 changed files with 5845 additions and 1020 deletions
+7
View File
@@ -23,3 +23,10 @@ _ReSharper*/
DataBackup/
*.db
/.claude/*
# Production secrets and configuration
auth-secrets.json
docker-compose.yml
docker-compose.override.yml
/WebApp/logs/*
+286 -233
View File
@@ -1,150 +1,209 @@
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);
// each student should be assigned at least one on site activity
if (_parameters.RequireOnSite)
RequireOnSiteActivity(model, x);
// students should have at least one regional event
if (_parameters.RequireRegional)
RequireRegionalEvent(model, x);
AtMostOneIndividualEvent(model, x);
IndividualEventsMustBeRanked(model, x);
OptimizeStudentEventRankings(model, x);
// students should have at maximum one individual event
AtMostOneIndividualEvent(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));
//EventHasInterestedStudent(model, x);
Debug.WriteLine($"Solver status: {cpSolverStatus}");
IndividualEventsMustBeRanked(model, x);
if (cpSolverStatus == CpSolverStatus.Infeasible)
{
Debug.WriteLine("Problem is infeasible - constraints cannot be satisfied");
}
OptimizeStudentEventRankings(model, x);
var eventAssignmentsList = GetEventAssignments(x, solver, cpSolverStatus);
var eventAssignmentSolution =
new EventAssignmentSolution
(
eventAssignmentsList.ToArray(),
cpSolverStatus.ToString(),
assignmentThresholdsList
);
Debug.WriteLine("Starting optimization");
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))
if (cpSolverStatus is not (CpSolverStatus.Optimal or CpSolverStatus.Feasible))
return [];
var eventAssignments =
from e in _allEvents
let students =
from s in _allStudents
where solver.BooleanValue(x[e, s])
let students =
from s in _allStudents
where solver.BooleanValue(x[e, s])
select _students[s]
where students.Any()
select new Team
{
Identifier = _events[e].Name,
Event = _events[e],
Identifier = _events[e].Name,
Event = _events[e],
Students = students.ToList()
};
return eventAssignments.ToList();
}
// Maximize student event rankings
/// <summary>
/// Objective function: Maximize student event rankings (students get higher-ranked events).
/// </summary>
private void OptimizeStudentEventRankings(CpModel model, BoolVar[,] x)
{
var maximizePicks = LinearExpr.NewBuilder();
foreach (var s in _allStudents)
{
var eventPickCoefficients = GetEventPickCoefficients(_events, _students[s].EventRankings);
@@ -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
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
? 0L
// TODO: MaxRank can be calculated
: StudentEventRanking.MaxRank - eventRank.Rank; // inverse
return eventRank == null
? 0L
: 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)
{
}
}
}
}
}
}
}
}
+16
View File
@@ -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();
}
}
+39
View File
@@ -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));
}
}
+68 -25
View File
@@ -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());
}
}
+53 -15
View File
@@ -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()
+13 -9
View File
@@ -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
+13
View File
@@ -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
View File
@@ -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`
-60
View File
@@ -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);
}
}
}
+37
View File
@@ -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");
}
}
}
+44 -20
View File
@@ -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();
}
+80
View File
@@ -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;
}
}
+178
View File
@@ -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;
}
+166
View File
@@ -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);
}
+100
View File
@@ -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;
}
+44 -5
View File
@@ -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}");
+160 -9
View File
@@ -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}");
}
}
+58 -41
View File
@@ -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);
var allTeams = TestEntityHandler.GetTeams(events, students);
var teams = allTeams;
// 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();
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();
var events = new[] { robotics, biotech, webDesign, videoGame, engineering, cybersecurity };
//var eventAssignment = new EventAssignment(events, students);
//var teams = eventAssignment.Solve();
// 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();
TeamSchedulerSolution solution;
if (true)
var students = new[] { alice, bob, carol, david, eve, frank, grace, henry };
// 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))}");
//}
}
}
+287
View File
@@ -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;
}
}
}
+3 -3
View File
@@ -12,13 +12,13 @@
var allMeetingTeams = schedule.SelectMany(t => t).Distinct();
}
<div class="bluewhite p-5">
<div class="bluewhite p-5">
@{
List<Tuple<Student, IEnumerable<Team>>> overlaps;
List<(Student student, IEnumerable<Team> teams)> overlaps;
}
@foreach (var timeslot in schedule)
{
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>
}
+115
View File
@@ -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");
}
}
}
+7
View File
@@ -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;
}
}
+9
View File
@@ -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;
}
+1 -1
View File
@@ -6,7 +6,7 @@ namespace WebApp
{
private int[]? _scheduledTeams;
public int[] UserId
public int[] ScheduledTeams
{
get => _scheduledTeams ?? [];
set
+3 -1
View File
@@ -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();
");
}
}
@@ -1,4 +1,5 @@
@page "/events/create"
@attribute [Authorize]
@inject AppDbContext context
@inject NavigationManager NavigationManager
@@ -10,24 +11,23 @@
<EditForm Model="EventDefinition" OnValidSubmit="OnValidSubmit" Enhance>
<AntiforgeryToken />
<DataAnnotationsValidator />
<MudGrid>
<MudItem xs="12" sm="7">
<MudPaper Class="pa-4">
<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");
}
}
}
@@ -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>
@@ -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,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">
&nbsp;@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)" : "")&nbsp;
</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);
}
}
@@ -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");
}
}
}
@@ -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">
@@ -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
@@ -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
@@ -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();
}
}
@@ -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");
}
}
@@ -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)
{
@@ -88,7 +74,7 @@ else
// To protect from overposting attacks, enable the specific properties you want to bind to.
// For more information, see https://learn.microsoft.com/aspnet/core/blazor/forms/#mitigate-overposting-attacks.
private async Task UpdateTeam()
{
{
//Context.Attach(Team!).Entity = EntityState.Modified;
Team.Students.Clear();
foreach (var s in _selectedStudents)
@@ -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();
@@ -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
@@ -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();
}
}
@@ -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
View File
@@ -1,4 +1,5 @@
@page "/"
@attribute [Authorize]
@inject IConfiguration Configuration
<PageTitle>Home</PageTitle>
+2
View File
@@ -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">
&nbsp;@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>&nbsp;
@{
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");
}
}
+17 -4
View File
@@ -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>
</Router>
<NotFound>
@{
navigationManager.NavigateTo("/login", true);
}
</NotFound>
</Router>
@@ -0,0 +1,55 @@
@using Microsoft.AspNetCore.Components.Web
<ErrorBoundary>
<ChildContent>
@ChildContent
</ChildContent>
<ErrorContent Context="ex">
<MudContainer MaxWidth="MaxWidth.Medium" Class="mt-8">
<MudPaper Elevation="3" Class="pa-6">
<MudAlert Severity="Severity.Error" Variant="Variant.Filled">
<MudText Typo="Typo.h5" Class="mb-2">
<MudIcon Icon="@Icons.Material.Filled.Error" Class="mr-2" />
An Error Occurred
</MudText>
@if (ShowDetails)
{
<MudText Typo="Typo.body2" Class="mt-4">
<strong>Error:</strong> @ex.Message
</MudText>
<MudText Typo="Typo.caption" Class="mt-2">
<strong>Stack Trace:</strong>
<pre style="overflow-x: auto;">@ex.StackTrace</pre>
</MudText>
}
else
{
<MudText Typo="Typo.body2">
Something went wrong. Please try refreshing the page or contact support if the problem persists.
</MudText>
}
</MudAlert>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
Class="mt-4"
OnClick="@(() => Navigation.NavigateTo("/", forceLoad: true))">
Return to Home
</MudButton>
</MudPaper>
</MudContainer>
</ErrorContent>
</ErrorBoundary>
@code {
[Parameter] public RenderFragment? ChildContent { get; set; }
[Inject] private IWebHostEnvironment Environment { get; set; } = default!;
[Inject] private NavigationManager Navigation { get; set; } = default!;
[Inject] private ILogger<AppErrorBoundary> Logger { get; set; } = default!;
private bool ShowDetails => Environment.IsDevelopment();
protected override void OnParametersSet()
{
Logger.LogError("Error boundary triggered");
}
}
@@ -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,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
@@ -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/>
@@ -20,4 +20,4 @@
<MudNavLink Href="/teams/assignment" Icon="@AppIcons.TeamAssignment">Team Assignment</MudNavLink>
</MudNavGroup>
</MudNavMenu>
</MudPaper>
</MudPaper>
+14
View File
@@ -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
View File
@@ -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();
+6 -1
View File
@@ -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>
+10
View File
@@ -4,5 +4,15 @@
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Serilog": {
"MinimumLevel": {
"Default": "Debug",
"Override": {
"Microsoft": "Warning",
"Microsoft.AspNetCore": "Warning",
"System": "Warning"
}
}
}
}
+10
View File
@@ -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"
+18
View File
@@ -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"
}
]
}
}
+40
View File
@@ -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