From 3daa3b81b3599dc74d94d4ffb8e002fb05196011 Mon Sep 17 00:00:00 2001 From: James Kolpack Date: Thu, 11 Sep 2025 11:49:48 -0400 Subject: [PATCH] Add Blazor WebApp and rework data handling to utilize Entity Framework --- Core/Calculation/DataProcessing.cs | 20 - Core/Calculation/EventAssigner.cs | 316 -------------- Core/Calculation/EventAssignment.cs | 394 ++++++++++++++++++ Core/Calculation/EventAssignmentSolution.cs | 13 + Core/Calculation/EventAssignmentThresholds.cs | 12 + .../Calculation/UnassignedScheduleStrategy.cs | 10 + .../Calculation/UnassignedStudentScheduler.cs | 19 +- Core/Core.csproj | 5 +- Core/Entities/AssignmentAssumption.cs | 10 - Core/Entities/AssignmentParameters.cs | 19 +- Core/Entities/AssignmentRequirement.cs | 8 + Core/Entities/CompetitiveEvent.cs | 71 ---- Core/Entities/EventAssignment.cs | 12 +- Core/Entities/EventDefinition.cs | 80 ++++ Core/Entities/EventStudentPicks.cs | 13 - Core/Entities/OfficerRole.cs | 15 + Core/Entities/PartialTeam.cs | 11 +- .../{Assumption.cs => Requirement.cs} | 2 +- Core/Entities/Student.cs | 121 +++--- Core/Entities/StudentEventRanking.cs | 12 + Core/Entities/StudentEventStatistics.cs | 32 ++ Core/Entities/Team.cs | 76 ++-- ...rser.cs => AssignmentRequirementParser.cs} | 14 +- Core/Parsers/CsvParserBase.cs | 8 +- Core/Parsers/EventDefinitionParser.cs | 18 +- Core/Parsers/EventOccurrenceParser.cs | 24 +- Core/Parsers/StudentEventRankingParser.cs | 77 ++++ Core/Parsers/StudentParser.cs | 79 +--- Core/Parsers/TeamParser.cs | 15 +- Data/AppDbContext.cs | 86 ++++ Data/Data.csproj | 29 ++ .../20250825173042_InitialCreate.Designer.cs | 167 ++++++++ .../20250825173042_InitialCreate.cs | 131 ++++++ ...6155947_AddStudentEventRanking.Designer.cs | 209 ++++++++++ .../20250826155947_AddStudentEventRanking.cs | 51 +++ ...250826181144_StudentProperties.Designer.cs | 227 ++++++++++ .../20250826181144_StudentProperties.cs | 80 ++++ .../20250827183503_StudentCleanup.Designer.cs | 227 ++++++++++ .../20250827183503_StudentCleanup.cs | 58 +++ ...0250827200441_StudentNameSplit.Designer.cs | 231 ++++++++++ .../20250827200441_StudentNameSplit.cs | 39 ++ ...903121202_StudentAddEmailPhone.Designer.cs | 256 ++++++++++++ .../20250903121202_StudentAddEmailPhone.cs | 99 +++++ Data/Migrations/AppDbContextModelSnapshot.cs | 253 +++++++++++ Data/Services/TsaEventServices.cs | 13 + DataBackup/ChapterOrganizer.db | Bin 0 -> 114688 bytes TSA Chapter Organizer.sln | 19 +- Tests/Calculation/DataProcessingTests.cs | 12 +- Tests/Calculation/EventAssignerTests.cs | 23 - Tests/Calculation/EventAssignmentTests.cs | 24 ++ Tests/Calculation/TeamSchedulerTest.cs | 8 +- Tests/Parsers/AssignmentAssumption_Tests.cs | 21 - Tests/Parsers/AssignmentRequirement_Tests.cs | 19 + Tests/Parsers/EventDefinitionParser_Tests.cs | 4 +- Tests/Parsers/EventOccurrenceParser_Tests.cs | 10 +- Tests/Parsers/StudentParser_Tests.cs | 6 +- Tests/Parsers/TeamParser_Tests.cs | 8 +- Tests/Parsers/TestEntityHandler.cs | 34 +- ...SA student & event - Event Definitions.csv | 37 ++ ...A student & event - Event Definitions.xlsx | Bin 0 -> 21057 bytes Tests/Tests.csproj | 5 +- Web-Original/Controllers/HomeController.cs | 88 ++-- Web-Original/Views/Home/Events.cshtml | 10 +- Web-Original/Views/Home/Nationals.cshtml | 54 +-- Web-Original/Views/Home/Regionals.cshtml | 20 +- Web-Original/Views/Home/Schedule.cshtml | 18 +- .../Views/Home/ScheduleTeamPartial.cshtml | 14 +- Web-Original/Views/Home/State.cshtml | 58 +-- .../Views/Home/StudentEventHandout.cshtml | 14 +- Web-Original/Views/Home/StudentEvents.cshtml | 38 +- Web-Original/Views/Home/Students.cshtml | 38 +- Web-Original/Views/Home/TeamGrid.cshtml | 4 +- Web-Original/Views/Home/Teams.cshtml | 38 +- Web-Original/Views/Shared/_Layout.cshtml | 6 +- Web-Original/Web-Original.csproj | 3 +- WebApp/ChapterSettings.cs | 14 + WebApp/Components/App.razor | 24 ++ WebApp/Components/Layout/MainLayout.razor | 27 ++ WebApp/Components/Layout/MainLayout.razor.css | 96 +++++ WebApp/Components/Layout/NavMenu.razor | 43 ++ WebApp/Components/Layout/NavMenu.razor.css | 105 +++++ WebApp/Components/Pages/Error.razor | 36 ++ .../Pages/EventDefinitionPages/Create.razor | 134 ++++++ .../Pages/EventDefinitionPages/Delete.razor | 122 ++++++ .../Pages/EventDefinitionPages/Details.razor | 80 ++++ .../Pages/EventDefinitionPages/Edit.razor | 177 ++++++++ .../Pages/EventDefinitionPages/Events.razor | 87 ++++ .../Pages/EventDefinitionPages/Index.razor | 91 ++++ WebApp/Components/Pages/Home.razor | 13 + WebApp/Components/Pages/Import.razor | 113 +++++ .../Pages/StudentPages/Create.razor | 57 +++ .../Pages/StudentPages/Delete.razor | 80 ++++ .../Pages/StudentPages/Details.razor | 64 +++ .../Components/Pages/StudentPages/Edit.razor | 100 +++++ .../Pages/StudentPages/EventRanking.razor | 154 +++++++ .../Pages/StudentPages/EventRankingEdit.razor | 165 ++++++++ .../Components/Pages/StudentPages/Index.razor | 70 ++++ .../Pages/TeamPages/Assignment.razor | 365 ++++++++++++++++ .../Components/Pages/TeamPages/Create.razor | 53 +++ WebApp/Components/Pages/TeamPages/Index.razor | 75 ++++ WebApp/Components/Routes.razor | 6 + WebApp/Components/_Imports.razor | 13 + WebApp/Models/AppIcons.cs | 57 +++ WebApp/Models/SharedSortableListGroup.cs | 16 + WebApp/Program.cs | 41 ++ WebApp/Properties/launchSettings.json | 58 +++ WebApp/WebApp.csproj | 34 ++ WebApp/appsettings.Development.json | 8 + WebApp/appsettings.json | 21 + WebApp/wwwroot/app.css | 91 ++++ WebApp/wwwroot/favicon.png | Bin 0 -> 1148 bytes 111 files changed, 6039 insertions(+), 946 deletions(-) delete mode 100644 Core/Calculation/DataProcessing.cs delete mode 100644 Core/Calculation/EventAssigner.cs create mode 100644 Core/Calculation/EventAssignment.cs create mode 100644 Core/Calculation/EventAssignmentSolution.cs create mode 100644 Core/Calculation/EventAssignmentThresholds.cs create mode 100644 Core/Calculation/UnassignedScheduleStrategy.cs delete mode 100644 Core/Entities/AssignmentAssumption.cs create mode 100644 Core/Entities/AssignmentRequirement.cs delete mode 100644 Core/Entities/CompetitiveEvent.cs create mode 100644 Core/Entities/EventDefinition.cs delete mode 100644 Core/Entities/EventStudentPicks.cs create mode 100644 Core/Entities/OfficerRole.cs rename Core/Entities/{Assumption.cs => Requirement.cs} (68%) create mode 100644 Core/Entities/StudentEventRanking.cs create mode 100644 Core/Entities/StudentEventStatistics.cs rename Core/Parsers/{AssignmentAssumptionParser.cs => AssignmentRequirementParser.cs} (57%) create mode 100644 Core/Parsers/StudentEventRankingParser.cs create mode 100644 Data/AppDbContext.cs create mode 100644 Data/Data.csproj create mode 100644 Data/Migrations/20250825173042_InitialCreate.Designer.cs create mode 100644 Data/Migrations/20250825173042_InitialCreate.cs create mode 100644 Data/Migrations/20250826155947_AddStudentEventRanking.Designer.cs create mode 100644 Data/Migrations/20250826155947_AddStudentEventRanking.cs create mode 100644 Data/Migrations/20250826181144_StudentProperties.Designer.cs create mode 100644 Data/Migrations/20250826181144_StudentProperties.cs create mode 100644 Data/Migrations/20250827183503_StudentCleanup.Designer.cs create mode 100644 Data/Migrations/20250827183503_StudentCleanup.cs create mode 100644 Data/Migrations/20250827200441_StudentNameSplit.Designer.cs create mode 100644 Data/Migrations/20250827200441_StudentNameSplit.cs create mode 100644 Data/Migrations/20250903121202_StudentAddEmailPhone.Designer.cs create mode 100644 Data/Migrations/20250903121202_StudentAddEmailPhone.cs create mode 100644 Data/Migrations/AppDbContextModelSnapshot.cs create mode 100644 Data/Services/TsaEventServices.cs create mode 100644 DataBackup/ChapterOrganizer.db delete mode 100644 Tests/Calculation/EventAssignerTests.cs create mode 100644 Tests/Calculation/EventAssignmentTests.cs delete mode 100644 Tests/Parsers/AssignmentAssumption_Tests.cs create mode 100644 Tests/Parsers/AssignmentRequirement_Tests.cs create mode 100644 Tests/Parsers/TestInput/2025-26 RMS TSA student & event - Event Definitions.csv create mode 100644 Tests/Parsers/TestInput/2025-26 RMS TSA student & event - Event Definitions.xlsx create mode 100644 WebApp/ChapterSettings.cs create mode 100644 WebApp/Components/App.razor create mode 100644 WebApp/Components/Layout/MainLayout.razor create mode 100644 WebApp/Components/Layout/MainLayout.razor.css create mode 100644 WebApp/Components/Layout/NavMenu.razor create mode 100644 WebApp/Components/Layout/NavMenu.razor.css create mode 100644 WebApp/Components/Pages/Error.razor create mode 100644 WebApp/Components/Pages/EventDefinitionPages/Create.razor create mode 100644 WebApp/Components/Pages/EventDefinitionPages/Delete.razor create mode 100644 WebApp/Components/Pages/EventDefinitionPages/Details.razor create mode 100644 WebApp/Components/Pages/EventDefinitionPages/Edit.razor create mode 100644 WebApp/Components/Pages/EventDefinitionPages/Events.razor create mode 100644 WebApp/Components/Pages/EventDefinitionPages/Index.razor create mode 100644 WebApp/Components/Pages/Home.razor create mode 100644 WebApp/Components/Pages/Import.razor create mode 100644 WebApp/Components/Pages/StudentPages/Create.razor create mode 100644 WebApp/Components/Pages/StudentPages/Delete.razor create mode 100644 WebApp/Components/Pages/StudentPages/Details.razor create mode 100644 WebApp/Components/Pages/StudentPages/Edit.razor create mode 100644 WebApp/Components/Pages/StudentPages/EventRanking.razor create mode 100644 WebApp/Components/Pages/StudentPages/EventRankingEdit.razor create mode 100644 WebApp/Components/Pages/StudentPages/Index.razor create mode 100644 WebApp/Components/Pages/TeamPages/Assignment.razor create mode 100644 WebApp/Components/Pages/TeamPages/Create.razor create mode 100644 WebApp/Components/Pages/TeamPages/Index.razor create mode 100644 WebApp/Components/Routes.razor create mode 100644 WebApp/Components/_Imports.razor create mode 100644 WebApp/Models/AppIcons.cs create mode 100644 WebApp/Models/SharedSortableListGroup.cs create mode 100644 WebApp/Program.cs create mode 100644 WebApp/Properties/launchSettings.json create mode 100644 WebApp/WebApp.csproj create mode 100644 WebApp/appsettings.Development.json create mode 100644 WebApp/appsettings.json create mode 100644 WebApp/wwwroot/app.css create mode 100644 WebApp/wwwroot/favicon.png diff --git a/Core/Calculation/DataProcessing.cs b/Core/Calculation/DataProcessing.cs deleted file mode 100644 index 747d986..0000000 --- a/Core/Calculation/DataProcessing.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Core.Entities; - -namespace Core.Calculation; - -public class DataProcessing -{ - public static EventStudentPicks[] GetEventStudentPicks(IList events, IList students) - { - return - students.SelectMany( - student => student.RankedEventPicks.Select((e, i) => (e, student, i + 1))) - .OrderBy(tuple => tuple.Item3) - .ThenByDescending(tuple => tuple.student.Grade + tuple.student.TsaYear) - .GroupBy(tuple => tuple.e) - .OrderBy(tuples => tuples.Key.Name) - .Select(tuples => - new EventStudentPicks(tuples.Key, tuples.Select(tuple => Tuple.Create(tuple.student, tuple.Item3)).ToList()) - ).ToArray(); - } -} \ No newline at end of file diff --git a/Core/Calculation/EventAssigner.cs b/Core/Calculation/EventAssigner.cs deleted file mode 100644 index 191c8a2..0000000 --- a/Core/Calculation/EventAssigner.cs +++ /dev/null @@ -1,316 +0,0 @@ -using System.Diagnostics; -using Core.Entities; -using Google.OrTools.Sat; -using IntVar = Google.OrTools.Sat.IntVar; - -namespace Core.Calculation -{ - public class EventAssigner - { - private readonly IList _events; - private readonly IList _students; - private readonly AssignmentParameters _parameters; - private readonly int[] _allEvents; - private readonly int[] _allStudents; - // how many students have picked each event? - private readonly int[] _eventPickCounts; - private IList _preAssigned = new List(); - private IList _prohibited = new List(); - private IList _droppedEvents; - private IList _includedEvents; - - public EventAssigner(IList events, IList students, AssignmentParameters parameters) - { - _events = events; - _students = students; - _parameters = parameters; - _allEvents = Enumerable.Range(0, _events.Count).ToArray(); - _allStudents = Enumerable.Range(0, _students.Count).ToArray(); - _eventPickCounts = new int[_allEvents.Length]; - _preAssigned = new List(); - _droppedEvents = new List(); - _includedEvents = new List(); - for (var i = 0; i < _events.Count; i++) - { - var e = _events[i]; - _eventPickCounts[i] = _students.Count(s => s.RankedEventPicks.Contains(e)); - } - } - - public void AssignToEvent(EventAssignment preAssigned) - { - _preAssigned.Add(preAssigned); - } - - - public void ExcludeFromEvent(EventAssignment prohibited) - { - _prohibited.Add(prohibited); - } - - public void RemoveEvent(IList events) - { - _droppedEvents = events; - } - public void IncludedEvents(IList events) - { - _includedEvents = events; - } - - public class SolutionPrinter : CpSolverSolutionCallback - { - private readonly IList _events; - private readonly IList _students; - private readonly BoolVar[,] _eventAssignment; - - public SolutionPrinter(IList events, IList 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) - { - - } - } - } - } - } - - public Team[] Solve() - { - - // 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}]"); - - foreach (var preAssignment in _preAssigned) - { - var e = _events.IndexOf(preAssignment.Event); - var s = _students.IndexOf(preAssignment.Student); - model.AddAssumption(x[e, s]); - } - - foreach (var prohibit in _prohibited) - { - var e = _events.IndexOf(prohibit.Event); - var s = _students.IndexOf(prohibit.Student); - - var prohibitVar = new List { x[e, s] }; - - model.AddLinearConstraint(LinearExpr.Sum(prohibitVar), 0, 0); - prohibitVar.Clear(); - } - - // Limit the capacity of each event - foreach (var e in _allEvents) - { - var evt = _events[e]; - var eventPickCounts = _eventPickCounts[e]; - - var evtMinTeamSize = evt.MinTeamSize; - var evtMaxTeamSize = evt.MaxTeamSize; - - var teamDivs = eventPickCounts / (evtMinTeamSize * 1.0); - if (_includedEvents.Contains(evt)) - teamDivs = 1; - - //var teamsCount = (int)Math.Ceiling(teamDivs); - var teamCount = (int)Math.Round(teamDivs); - if (teamCount > evt.MaxTeamCountState) - teamCount = evt.MaxTeamCountState; - - // limit to one team for group events - if (_parameters.LimitTeamsToOne && evt.Format is EventFormat.Team && teamCount > 1) teamCount = 1; - - if (evt.Name == "Tech Bowl") - teamCount = 1; - - var eventCapacity = new List(); - foreach (var s in _allStudents) - { - eventCapacity.Add(x[e, s]); - } - - if (_droppedEvents != null && _droppedEvents.Contains(evt)) - teamCount = 0; - - var lb = evtMinTeamSize * teamCount; - var ub = Math.Min(evtMaxTeamSize * teamCount, _parameters.TeamSizeLimit); - model.AddLinearConstraint(LinearExpr.Sum(eventCapacity), lb, ub); - Debug.WriteLine($"{evt.Name,30}\t{evt.Format,-10}\t{lb} - {ub}"); - - model.Minimize(LinearExpr.Sum(eventCapacity)); - eventCapacity.Clear(); - } - - // Limit the number of events a student is assigned - foreach (var s in _allStudents) - { - var student = _students[s]; - - var studentCapacity = new List(); - foreach (var e in _allEvents) - { - studentCapacity.Add(x[e, s]); - } - - model.AddLinearConstraint(LinearExpr.Sum(studentCapacity), _parameters.AssignmentLowerBound, _parameters.AssignmentUpperBound); - studentCapacity.Clear(); - } - - var eventEffortCoefficients = GetEventEffortCoefficients(_events); - foreach (var s in _allStudents) - { - var effortVar = new BoolVar[_allEvents.Length]; - - foreach (var e in _allEvents) - { - effortVar[e] = x[e, s]; - } - var student = _students[s]; - var experienceOffset = 0; - switch (student.TsaYear) - { - case 1: experienceOffset = 1; break; - default: break; - } - - var ub = _parameters.EffortUpperBound - experienceOffset; - var lb = _parameters.EffortLowerBound; - if (ub <= lb) - lb = ub - 1; - - model.Add(LinearExpr.WeightedSum(effortVar, eventEffortCoefficients) >= lb); - model.Add(LinearExpr.WeightedSum(effortVar, eventEffortCoefficients) <= ub); - } - - // each student should be assigned at least one on site activity event - foreach (var s in _allStudents) - { - var onSiteActivity = new List(); - foreach (var e in _allEvents) - { - if (_events[e].OnSiteActivity) - onSiteActivity.Add(x[e, s]); - } - - if (_parameters.RequireOnSite) - model.AddAtLeastOne(onSiteActivity); - onSiteActivity.Clear(); - } - - // students should have at maximum one individual event - foreach (var s in _allStudents) - { - var individualEvent = new List(); - foreach (var e in _allEvents) - { - if (_events[e].Format == EventFormat.Individual) - individualEvent.Add(x[e, s]); - } - - model.AddAtMostOne(individualEvent); - individualEvent.Clear(); - } - - // students should have at maximum regional events - foreach (var s in _allStudents) - { - var regionalEvent = new List(); - foreach (var e in _allEvents) - { - if (_events[e].RegionalEvent) - regionalEvent.Add(x[e, s]); - } - - if (_parameters.RequireRegional) - model.AddLinearConstraint(LinearExpr.Sum(regionalEvent), 1, 2); - regionalEvent.Clear(); - } - - var maximizePicks = LinearExpr.NewBuilder(); - // optimize student selections - foreach (var s in _allStudents) - { - var eventPickCoefficients = GetEventPickCoefficients(_students[s].RankedEventPicks, _events); - - foreach (var e in _allEvents) - { - maximizePicks.AddTerm(x[e, s], eventPickCoefficients[e]); - } - } - model.Maximize(maximizePicks); - - var solver = new CpSolver(); - var cpSolverStatus = solver.Solve(model); - - // print solver status - Console.WriteLine($"Solver status: {cpSolverStatus}"); - - var eventAssignmentsList = new List(); - - if (cpSolverStatus == CpSolverStatus.Optimal || cpSolverStatus == CpSolverStatus.Feasible) - { - foreach (var e in _allEvents) - { - var students = new List(); - foreach (var s in _allStudents) - { - if (solver.BooleanValue(x[e, s])) - { - students.Add(_students[s]); - //Console.WriteLine($"{_events[evt].Name} : {_students[s].Name}"); - } - } - if (students.Count > 0) - eventAssignmentsList.Add(new Team(_events[e].Name, _events[e], students)); - } - } - else - { - //Console.WriteLine("No solution found."); - } - - return eventAssignmentsList.ToArray(); - } - - private long[] GetEventPickCoefficients(IList rankedEvents, IEnumerable events) - { - var eventPickCount = rankedEvents.Count; - - return - events.Select(e => - { - var eventPickIndex = rankedEvents.IndexOf(e); - return eventPickIndex switch - { - 0 => eventPickCount + 1, - 1 => eventPickCount + 0, - > 1 => eventPickCount , - _ => 0L - }; - }).ToArray(); - } - - - private long[] GetEventEffortCoefficients(IEnumerable events) - { - return - events.Select(e => e.Name == "Tech Bowl" ? 0 : e.LevelOfEffort.HasValue ? e.LevelOfEffort.Value : 10L).ToArray(); - } - } -} \ No newline at end of file diff --git a/Core/Calculation/EventAssignment.cs b/Core/Calculation/EventAssignment.cs new file mode 100644 index 0000000..5edc7a9 --- /dev/null +++ b/Core/Calculation/EventAssignment.cs @@ -0,0 +1,394 @@ +using System.Diagnostics; +using System.Security.Cryptography.X509Certificates; +using Core.Entities; +using Google.OrTools.Sat; +using Microsoft.EntityFrameworkCore.Metadata; +using IntVar = Google.OrTools.Sat.IntVar; + +namespace Core.Calculation +{ + public class EventAssignment + { + private readonly IList _events; + private readonly IList _students; + private readonly AssignmentParameters _parameters; + private readonly int[] _allEvents; + private readonly int[] _allStudents; + // how many students have picked each eventDefinition? + private readonly int[] _eventPickCounts; + private IList _assignmentRequirements = new List(); + private IList _droppedEvents = new List(); + private IList _includedEvents = new List(); + private IList _twoTeams = new List(); + + public EventAssignment(IList events, IList students, AssignmentParameters parameters) + { + _events = events; + _students = students; + _parameters = parameters; + _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); + } + } + + public void AddAssignmentRequirement(AssignmentRequirement assignmentRequirement) + { + _assignmentRequirements.Add(assignmentRequirement); + } + + public void RemoveEvents(IList events) + { + _droppedEvents = events; + } + + public void IncludedEvents(IList events) + { + _includedEvents = events; + } + + public void AllowTwoTeams(IList events) + { + _twoTeams = events; + } + + + public async Task Solve() + { + Debug.WriteLine(_parameters); + + // 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}]"); + + AddAssignmentRequirements(model, x); + + var assignmentThresholdsList = AddEventAssignmentThresholds(model, x); + + LimitStudentAssignment(model, x); + + // set the range for level of effort + var eventEffortCoefficients = GetEventEffortCoefficients(_events); + SetLevelOfEffort(model, x, eventEffortCoefficients); + + // each student should be assigned at least one on site activity eventDefinition + if (_parameters.RequireOnSite) + RequireOnSiteActivity(model, x); + + // students should have at maximum one individual eventDefinition + LimitIndividualEvent(model, x); + + // students should have at least one regional event + if (_parameters.RequireRegional) + RequireRegionalEvent(model, x); + + OptimizeStudentEventRankings(model, x); + + Debug.WriteLine("Starting optimization"); + var solver = new CpSolver(); + var cpSolverStatus = await Task.Run(() => solver.Solve(model)); + + // print solver status + Debug.WriteLine($"Solver status: {cpSolverStatus}"); + + var eventAssignmentsList = GetEventAssignments(x, solver, cpSolverStatus); + + var eventAssignmentSolution = + new EventAssignmentSolution + ( + eventAssignmentsList.ToArray(), + cpSolverStatus.ToString(), + assignmentThresholdsList + ); + + return eventAssignmentSolution; + } + + private List GetEventAssignments(BoolVar[,] x, CpSolver solver, CpSolverStatus cpSolverStatus) + { + var eventAssignmentsList = new List(); + + if (cpSolverStatus == CpSolverStatus.Optimal || cpSolverStatus == CpSolverStatus.Feasible) + { + foreach (var e in _allEvents) + { + var students = new List(); + foreach (var s in _allStudents) + { + if (solver.BooleanValue(x[e, s])) + { + students.Add(_students[s]); + + } + } + if (students.Count > 0) + eventAssignmentsList.Add(new Team { TeamId = _events[e].Name, Event = _events[e], Students = students}); + } + } + + return eventAssignmentsList; + } + + private void OptimizeStudentEventRankings(CpModel model, BoolVar[,] x) + { + var maximizePicks = LinearExpr.NewBuilder(); + + // optimize student event rankings + foreach (var s in _allStudents) + { + var eventPickCoefficients = GetEventPickCoefficients(_events, _students[s].EventRankings); + + foreach (var e in _allEvents) + { + maximizePicks.AddTerm(x[e, s], eventPickCoefficients[e]); + } + } + model.Maximize(maximizePicks); + } + + private void RequireRegionalEvent(CpModel model, BoolVar[,] x) + { + foreach (var s in _allStudents) + { + var regionalEvent = new List(); + foreach (var e in _allEvents) + { + if (_events[e].RegionalEvent) + regionalEvent.Add(x[e, s]); + } + + //if (_parameters.RequireRegional) + model.AddLinearConstraint(LinearExpr.Sum(regionalEvent), 1, 2); + regionalEvent.Clear(); + } + } + + private void LimitIndividualEvent(CpModel model, BoolVar[,] x) + { + foreach (var s in _allStudents) + { + var individualEvent = new List(); + foreach (var e in _allEvents) + { + if (_events[e].EventFormat == EventFormat.Individual) + individualEvent.Add(x[e, s]); + } + + model.AddAtMostOne(individualEvent); + individualEvent.Clear(); + } + } + + private void RequireOnSiteActivity(CpModel model, BoolVar[,] x) + { + foreach (var s in _allStudents) + { + var onSiteActivity = new List(); + foreach (var e in _allEvents) + { + if (_events[e].OnSiteActivity) + onSiteActivity.Add(x[e, s]); + } + + //if (_parameters.RequireOnSite) + model.AddAtLeastOne(onSiteActivity); + onSiteActivity.Clear(); + } + } + + private void SetLevelOfEffort(CpModel model, BoolVar[,] x, long[] eventEffortCoefficients) + { + foreach (var s in _allStudents) + { + var effortVar = new BoolVar[_allEvents.Length]; + + foreach (var e in _allEvents) + { + effortVar[e] = x[e, s]; + } + var student = _students[s]; + var experienceOffset = 0; + switch (student.TsaYear) + { + case 1: experienceOffset = 1; break; + default: break; + } + + var ub = _parameters.EffortUpperBound - experienceOffset; + var lb = _parameters.EffortLowerBound; + if (ub <= lb) + lb = ub - 1; + + model.Add(LinearExpr.WeightedSum(effortVar, eventEffortCoefficients) >= lb); + model.Add(LinearExpr.WeightedSum(effortVar, eventEffortCoefficients) <= ub); + } + } + + private void LimitStudentAssignment(CpModel model, BoolVar[,] x) + { + // Limit the number of events a student is assigned + foreach (var s in _allStudents) + { + var studentCapacity = new List(); + foreach (var e in _allEvents) + { + studentCapacity.Add(x[e, s]); + } + + model.AddLinearConstraint( + LinearExpr.Sum(studentCapacity), _parameters.EventsLowerBound, _parameters.EventsUpperBound); + studentCapacity.Clear(); + } + } + + private List AddEventAssignmentThresholds(CpModel model, BoolVar[,] x) + { + var assignmentThresholdsList = new List(); + // Limit the capacity of each event + foreach (var e in _allEvents) + { + var evt = _events[e]; + var eventPickCounts = _eventPickCounts[e]; + + 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.MaxTeamCountState) + teamCount = evt.MaxTeamCountState; + + // limit to one team for group events + if (_parameters.LimitTeamsToOne + && evt.EventFormat is EventFormat.Team + && teamCount > 1 + && !_twoTeams.Contains(evt) + ) + teamCount = 1; + + if (evt.Name == "Tech Bowl") + teamCount = 1; + + var eventCapacity = new List(); + foreach (var s in _allStudents) + { + eventCapacity.Add(x[e, s]); + } + + if (_droppedEvents != null && _droppedEvents.Contains(evt)) + teamCount = 0; + + var lb = evtMinTeamSize * teamCount; + var ub = Math.Min(evtMaxTeamSize * teamCount, _parameters.TeamSizeLimit * teamCount); + + assignmentThresholdsList.Add( + new EventAssignmentThresholds + { + Event = evt, + TeamCount = teamCount, + LowerBound = evtMinTeamSize, + UpperBound = evtMaxTeamSize, + StudentRankingCount = eventPickCounts + }); + + + model.AddLinearConstraint(LinearExpr.Sum(eventCapacity), lb, ub); + + Debug.WriteLine($"{evt.Name,30}\t{evt.EventFormat,-10}\t{lb} - {ub}"); + + model.Minimize(LinearExpr.Sum(eventCapacity)); + eventCapacity.Clear(); + } + + return assignmentThresholdsList; + } + + private void AddAssignmentRequirements(CpModel model, BoolVar[,] x) + { + foreach (var includedAssignment in _assignmentRequirements.Where(e => e.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 e = _events.IndexOf(excludedAssignment.EventDefinition); + var s = _students.IndexOf(excludedAssignment.Student); + + var prohibitVar = new List { x[e, s] }; + + model.AddLinearConstraint(LinearExpr.Sum(prohibitVar), 0, 0); + prohibitVar.Clear(); + } + } + + private static long[] GetEventPickCoefficients(IList events, List eventRankings) + { + return + events.Select(e => + { + var eventRank = eventRankings.FirstOrDefault(er => er.EventDefinition == e); + return + eventRank == null + ? 0L + : StudentEventRanking.MaxRank - eventRank.Rank; // inverse + }).ToArray(); + } + + private long[] GetEventEffortCoefficients(IEnumerable events) + { + return + events.Select( + e => + //e.Name == "Tech Bowl" + // ? 0 // Tech bowl gets requires no effort? + //: + e.LevelOfEffort ?? 10L + ).ToArray(); + } + + public class SolutionPrinter : CpSolverSolutionCallback + { + private readonly IList _events; + private readonly IList _students; + private readonly BoolVar[,] _eventAssignment; + + public SolutionPrinter(IList events, IList 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) + { + + } + } + } + } + } + } +} \ No newline at end of file diff --git a/Core/Calculation/EventAssignmentSolution.cs b/Core/Calculation/EventAssignmentSolution.cs new file mode 100644 index 0000000..5a71d5e --- /dev/null +++ b/Core/Calculation/EventAssignmentSolution.cs @@ -0,0 +1,13 @@ +using Core.Entities; + +namespace Core.Calculation; + +public class EventAssignmentSolution( + Team[] teams, + string status, + List assignmentThresholds) +{ + public Team[] Teams { get; set; } = teams; + public string Status { get; set; } = status; + public List AssignmentThresholds { get; } = assignmentThresholds; +} \ No newline at end of file diff --git a/Core/Calculation/EventAssignmentThresholds.cs b/Core/Calculation/EventAssignmentThresholds.cs new file mode 100644 index 0000000..c836d3b --- /dev/null +++ b/Core/Calculation/EventAssignmentThresholds.cs @@ -0,0 +1,12 @@ +using Core.Entities; + +namespace Core.Calculation; + +public class EventAssignmentThresholds +{ + public EventDefinition Event { get; set; } + public int TeamCount { get; set; } + public int LowerBound { get; set; } + public int UpperBound { get; set; } + public int StudentRankingCount { get; set; } +} \ No newline at end of file diff --git a/Core/Calculation/UnassignedScheduleStrategy.cs b/Core/Calculation/UnassignedScheduleStrategy.cs new file mode 100644 index 0000000..130ace4 --- /dev/null +++ b/Core/Calculation/UnassignedScheduleStrategy.cs @@ -0,0 +1,10 @@ +namespace Core.Calculation; + +public enum UnassignedScheduleStrategy +{ + BiggestGroup, + IndividualEvents, + AnyNotMeetingAlready, + LevelOfEffort, + Any +} \ No newline at end of file diff --git a/Core/Calculation/UnassignedStudentScheduler.cs b/Core/Calculation/UnassignedStudentScheduler.cs index b957e53..4139cf7 100644 --- a/Core/Calculation/UnassignedStudentScheduler.cs +++ b/Core/Calculation/UnassignedStudentScheduler.cs @@ -2,15 +2,6 @@ namespace Core.Calculation; -public enum UnassignedScheduleStrategy -{ - BiggestGroup, - IndividualEvents, - AnyNotMeetingAlready, - LevelOfEffort, - Any -} - public class UnassignedStudentScheduler { private readonly IList _students; @@ -86,21 +77,21 @@ public class UnassignedStudentScheduler _teams .Where(t => scheduledTeams.All(st => st.Name != t.Name)) .Select(t => t.CloneWithOmittedStudents(assignedStudents)) - .Where(t => t.Students.Count > 1) //|| t.Event.Format is EventFormat.Individual + .Where(t => t.Students.Count > 1) //|| t.Event.EventFormat is EventFormat.Individual //.OrderBy(t => scheduledTeams.Count(st => st.Name == t.Name)) .OrderByDescending(t => t.Students.Count); // select descending greatest number of students assigned - //.ThenBy(t => Random.Shared.Next()); // todo: sort by student historic record of event assignment + //.ThenBy(t => Random.Shared.Next()); // todo: sort by student historic record of eventDefinition assignment // find individual events unassigned students can work on private IEnumerable GetAvailableTeams_Individual( IEnumerable scheduledTeams, IEnumerable assignedStudents) => _teams .Where(t => scheduledTeams.All(st => st.Name != t.Name)) - .Where(t => t.Event.Format == EventFormat.Individual || t.Students.Count == 1) + .Where(t => t.Event.EventFormat == EventFormat.Individual || t.Students.Count == 1) .Select(t => t.CloneWithOmittedStudents(assignedStudents)) .Where(t => t.Students.Count > 0); - // find any unassigned event students can work on + // find any unassigned eventDefinition students can work on private IEnumerable GetAvailableTeams_AnyNotMeetingAlready( IEnumerable scheduledTeams, IEnumerable assignedStudents) => _teams @@ -121,7 +112,7 @@ public class UnassignedStudentScheduler _teams .Where(t => scheduledTeams.All(st => st.Name != t.Name)) .Select(t => t.CloneWithOmittedStudents(assignedStudents)) - .Where(t => t.Students.Count > 1) //|| t.Event.Format is EventFormat.Individual + .Where(t => t.Students.Count > 1) //|| t.Event.EventFormat is EventFormat.Individual //.OrderBy(t => scheduledTeams.Count(st => st.Name == t.Name)) .OrderByDescending(t => t.Event.LevelOfEffort); // select descending greatest number of students assigned diff --git a/Core/Core.csproj b/Core/Core.csproj index 52af0da..66a477a 100644 --- a/Core/Core.csproj +++ b/Core/Core.csproj @@ -1,13 +1,14 @@  Exe - net8.0 + net9.0 enable enable - + + \ No newline at end of file diff --git a/Core/Entities/AssignmentAssumption.cs b/Core/Entities/AssignmentAssumption.cs deleted file mode 100644 index c8bed04..0000000 --- a/Core/Entities/AssignmentAssumption.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Core.Entities; - -public class AssignmentAssumption(CompetitiveEvent @event, Student student, Assumption assumption) -{ - public CompetitiveEvent Event { get; } = @event; - public Student Student { get; } = student; - public Assumption Assumption { get; } = assumption; - - public EventAssignment EventAssignment => new(@event, student); -} \ No newline at end of file diff --git a/Core/Entities/AssignmentParameters.cs b/Core/Entities/AssignmentParameters.cs index cf324c0..404fd64 100644 --- a/Core/Entities/AssignmentParameters.cs +++ b/Core/Entities/AssignmentParameters.cs @@ -3,8 +3,8 @@ public class AssignmentParameters( int effortLowerBound = 6, int effortUpperBound = 8, - int assignmentLowerBound = 2, - int assignmentUpperBound = 4, + int eventsLowerBound = 2, + int eventsUpperBound = 4, int teamSizeLimit = 4, bool limitTeamsToOne = true, bool requireRegional = true, @@ -12,11 +12,20 @@ { public int EffortLowerBound { get; set; } = effortLowerBound; public int EffortUpperBound { get; set; } = effortUpperBound; - public int AssignmentLowerBound { get; set; } = assignmentLowerBound; - public int AssignmentUpperBound { get; set; } = assignmentUpperBound; + public int EventsLowerBound { get; set; } = eventsLowerBound; + public int EventsUpperBound { get; set; } = eventsUpperBound; public int TeamSizeLimit { get; set; } = teamSizeLimit; public bool LimitTeamsToOne { get; set; } = limitTeamsToOne; public bool RequireRegional { get; set; } = requireRegional; public bool RequireOnSite { get; set; } = requireOnSite; - } + + public override string ToString() + { + return $"Team Size Limit: {TeamSizeLimit}" + Environment.NewLine + + $"Require Regional: {RequireRegional}" + Environment.NewLine + + $"Require On-site: {RequireOnSite}" + Environment.NewLine + + $"Events Range: [{EventsLowerBound}-{EventsUpperBound}]" + Environment.NewLine + + $"Effort Range: [{EffortLowerBound}-{EffortUpperBound}]"; + } + } } \ No newline at end of file diff --git a/Core/Entities/AssignmentRequirement.cs b/Core/Entities/AssignmentRequirement.cs new file mode 100644 index 0000000..178df4c --- /dev/null +++ b/Core/Entities/AssignmentRequirement.cs @@ -0,0 +1,8 @@ +namespace Core.Entities; + +public class AssignmentRequirement(EventDefinition eventDefinition, Student student, Requirement requirement) +{ + public EventDefinition EventDefinition { get; } = eventDefinition; + public Student Student { get; } = student; + public Requirement Requirement { get; } = requirement; +} \ No newline at end of file diff --git a/Core/Entities/CompetitiveEvent.cs b/Core/Entities/CompetitiveEvent.cs deleted file mode 100644 index 87cf811..0000000 --- a/Core/Entities/CompetitiveEvent.cs +++ /dev/null @@ -1,71 +0,0 @@ -namespace Core.Entities; - -public class CompetitiveEvent -{ - public string Name { get; set; } - public string ShortName { get; set; } - public EventFormat Format { get; set; } - public int MinTeamSize { get; set; } - public int MaxTeamSize { get; set; } - - public string TeamSize => - MinTeamSize == MaxTeamSize - ? MinTeamSize.ToString() - : $"{MinTeamSize.ToString()}-{MaxTeamSize.ToString()}"; - - public string SemifinalistActivity { get; set; } - - public bool InterviewOrPresentation - => SemifinalistActivity.Contains("Interview") || SemifinalistActivity.Contains("Presentation"); - - public bool OnSiteActivity - => SemifinalistActivity.Contains("Challenge") - || SemifinalistActivity.Contains("Race") - || SemifinalistActivity.Contains("Speech") - || SemifinalistActivity.Contains("Test") - || SemifinalistActivity.Contains("Flight") - || SemifinalistActivity.Contains("Debate") - || SemifinalistActivity.Contains("Photography") - || SemifinalistActivity.Contains("Build") - || Name.Contains("Chapter") - || Name.Contains("Essay") - || SemifinalistActivity.Contains("Fly"); - - public string RegionalNotes { get; set; } - - public int MaxTeamCountState { get; set; } - public bool RegionalEvent { get; set; } - - public bool RegionalPresubmit { get; set; } - public bool StatePresubmission { get; set; } - public bool StatePretesting { get; set; } - public bool StatePreliminaryRound { get; set; } - - public string Documentation { get; set; } - - public string Eligibility { get; set; } - public string Theme { get; set; } - public string Description { get; set; } - public int? LevelOfEffort { get; set; } - - public override string ToString() - { - return Name; - } - - public static readonly CompetitiveEvent GeneralSchedule = new(){Name = "General Schedule"}; - public static readonly CompetitiveEvent VotingDelegates = new(){Name = "Voting Delegates"}; - - - public string EventAttributes () - { - var st = new List(); - - if (Format is EventFormat.Individual) - st.Add( "Ind."); - if (RegionalEvent) - st.Add( "Reg."); - - return string.Join(", ", st); - } -} \ No newline at end of file diff --git a/Core/Entities/EventAssignment.cs b/Core/Entities/EventAssignment.cs index d1c5b1d..47cff9a 100644 --- a/Core/Entities/EventAssignment.cs +++ b/Core/Entities/EventAssignment.cs @@ -1,13 +1,7 @@ namespace Core.Entities; -public class EventAssignment +public class EventAssignment(EventDefinition eventDefinition, Student student) { - public CompetitiveEvent Event { get; } - public Student Student { get; } - - public EventAssignment(CompetitiveEvent @event, Student student) - { - Event = @event; - Student = student; - } + public EventDefinition EventDefinition { get; } = eventDefinition; + public Student Student { get; } = student; } \ No newline at end of file diff --git a/Core/Entities/EventDefinition.cs b/Core/Entities/EventDefinition.cs new file mode 100644 index 0000000..97f9e29 --- /dev/null +++ b/Core/Entities/EventDefinition.cs @@ -0,0 +1,80 @@ +using System.ComponentModel.DataAnnotations; + +namespace Core.Entities; + +public class EventDefinition +{ + public int Id { get; set; } + public string Name { get; set; } + public string? ShortName { get; set; } + public EventFormat EventFormat { get; set; } + + [Range(1, 6)] + public int MinTeamSize { get; set; } + + [Range(1, 6)] + public int MaxTeamSize { get; set; } + + public string TeamSize => + MinTeamSize == MaxTeamSize + ? MinTeamSize.ToString() + : $"{MinTeamSize.ToString()}-{MaxTeamSize.ToString()}"; + + public string? SemifinalistActivity { get; set; } + + public bool InterviewOrPresentation + => SemifinalistActivity != null && (SemifinalistActivity.Contains("Interview") || SemifinalistActivity.Contains("Presentation")); + + public bool OnSiteActivity + => SemifinalistActivity != null + && (SemifinalistActivity.Contains("Challenge") + || SemifinalistActivity.Contains("Race") + || SemifinalistActivity.Contains("Speech") + || SemifinalistActivity.Contains("Test") + || SemifinalistActivity.Contains("Flight") + || Name.Contains("Flight") + || SemifinalistActivity.Contains("Debate") + || SemifinalistActivity.Contains("Photography") + || SemifinalistActivity.Contains("Build") + || Name.Contains("Chapter") + || Name.Contains("Podcast")); + + public string? Notes { get; set; } + + [Range(1, 3)] + public int MaxTeamCountState { get; set; } + public bool RegionalEvent { get; set; } + + public bool RegionalPresubmit { get; set; } + public bool StatePresubmission { get; set; } + public bool StatePretesting { get; set; } + public bool StatePreliminaryRound { get; set; } + + public string? Documentation { get; set; } + + public string Eligibility { get; set; } + public string? Theme { get; set; } + public string? Description { get; set; } + public int? LevelOfEffort { get; set; } + + public override string ToString() + { + return Name; + } + + public static readonly EventDefinition GeneralSchedule = new(){Name = "General Schedule"}; + public static readonly EventDefinition VotingDelegates = new(){Name = "Voting Delegates"}; + + + public string EventAttributes () + { + var st = new List(); + + if (EventFormat is EventFormat.Individual) + st.Add( "Ind."); + if (RegionalEvent) + st.Add( "Reg."); + + return string.Join(", ", st); + } +} \ No newline at end of file diff --git a/Core/Entities/EventStudentPicks.cs b/Core/Entities/EventStudentPicks.cs deleted file mode 100644 index bc352ac..0000000 --- a/Core/Entities/EventStudentPicks.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace Core.Entities; - -public class EventStudentPicks -{ - public CompetitiveEvent Event { get; } - public IList> StudentPicks { get; } - - public EventStudentPicks(CompetitiveEvent @event, IList> studentPicks) - { - Event = @event; - StudentPicks = studentPicks; - } -} \ No newline at end of file diff --git a/Core/Entities/OfficerRole.cs b/Core/Entities/OfficerRole.cs new file mode 100644 index 0000000..05c303b --- /dev/null +++ b/Core/Entities/OfficerRole.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; + +namespace Core.Entities; + +public enum OfficerRole +{ + President=1, + [Display(Name = "Vice President")] + VicePresident=2, + Secretary=3, + Treasurer=4, + Reporter=5, + [Display(Name = "Sergeant at Arms")] + SergeantAtArms =6 +} \ No newline at end of file diff --git a/Core/Entities/PartialTeam.cs b/Core/Entities/PartialTeam.cs index 2581917..99385e7 100644 --- a/Core/Entities/PartialTeam.cs +++ b/Core/Entities/PartialTeam.cs @@ -2,17 +2,12 @@ public class PartialTeam : Team { - public IList OmittedStudents { get; } + public IList OmittedStudents { get; set; } - public PartialTeam(string name, CompetitiveEvent @event, IList students, IList omittedStudents) : base(name, @event, students) - { - OmittedStudents = omittedStudents; - } - - public override Team CloneWithOmittedStudents(IEnumerable studentsToOmit) + public override Team CloneWithOmittedStudents(IEnumerable studentsToOmit) { var remainingStudents = Students.Where(s => !studentsToOmit.Contains(s)).ToList(); var omittedStudents = OmittedStudents.Union(Students.Where(studentsToOmit.Contains)).Distinct().ToList(); - return new PartialTeam(Name, Event, remainingStudents, omittedStudents ); + return new PartialTeam{TeamId = Name, Event = Event, Students = remainingStudents, OmittedStudents = omittedStudents }; } } \ No newline at end of file diff --git a/Core/Entities/Assumption.cs b/Core/Entities/Requirement.cs similarity index 68% rename from Core/Entities/Assumption.cs rename to Core/Entities/Requirement.cs index 8420aa9..6ca878c 100644 --- a/Core/Entities/Assumption.cs +++ b/Core/Entities/Requirement.cs @@ -1,6 +1,6 @@ namespace Core.Entities; -public enum Assumption +public enum Requirement { Include, Exclude diff --git a/Core/Entities/Student.cs b/Core/Entities/Student.cs index fde7c99..c8fdf8d 100644 --- a/Core/Entities/Student.cs +++ b/Core/Entities/Student.cs @@ -1,74 +1,77 @@ -using System.Text.RegularExpressions; +using System.ComponentModel.DataAnnotations; +using static System.Text.RegularExpressions.Regex; namespace Core.Entities; public class Student { - public string Name { get; set; } + public int Id { get; set; } - public string LastNameFirstName + [Required] + [StringLength(50,MinimumLength = 2)] + [Display(Name = "First Name")] + public string FirstName { get; set; } + + [Required] + [StringLength(50, MinimumLength = 2)] + [Display(Name = "Last Name")] + public string LastName { get; set; } + + [Range(5,12)] + [Display(Name = "Grade")] + public int Grade { get; set; } + + [EmailAddress] + [Display(Name = "Email Address")] + public string? Email { get; set; } + + [Phone] + [Display(Name = "Phone Number")] + public string? PhoneNumber { get; set; } + + [Display(Name = "TSA Year")] + public int TsaYear { get; set; } + + [Display(Name = "State Id")] + public string? StateId { get; set; } + + [Display(Name = "Regional Id")] + public string? RegionalId { get; set; } + + [Display(Name = "National Id")] + public string? NationalId { get; set; } + + [Display(Name = "Officer Role")] + public OfficerRole? OfficerRole { get; set; } + + public List Teams { get; set; } = null; + public List RankedEvents { get; } = []; + public List EventRankings { get; } = []; + + + public string Name => FirstNameLastName; + + public string LastNameFirstName => $"{LastName}, {FirstName}"; + + public string FirstNameLastName => $"{FirstName} {LastName}"; + + public static Tuple ParseNameParts(string fullName) { - get - { - if (Name.Contains(',')) return Name; - var match = Regex.Match(Name, @"(.*)\s(.*)"); - if (match.Success) - return $"{match.Groups[2].Value}, {match.Groups[2].Value} "; - return Name; - } + var match = Match(fullName, @"(.*),\s*(.*)"); + if (match.Success) + return Tuple.Create(match.Groups[2].Value, match.Groups[1].Value); + match = Match(fullName, @"(.*)\s*(.*)"); + return + match.Success + ? Tuple.Create(match.Groups[1].Value, match.Groups[2].Value) + : new Tuple(fullName, string.Empty); } - - public string FirstNameLastName - { - get - { - if (!Name.Contains(',')) return Name; - var match = Regex.Match(Name, @"(.*),\s*(.*)"); - if (match.Success) - return $"{match.Groups[2].Value} {match.Groups[1].Value}"; - return Name; - } - } - - public string FirstName - { - get - { - var match = Regex.Match(LastNameFirstName, @"(.*),\s*(.*)"); - if (match.Success) - return $"{match.Groups[2].Value}"; - return Name; - } - } - - public int Grade { get; } - public string StateID { get; } - public string RegionalID { get; } - public string NationalID { get; } - public int TsaYear { get; } - public string Officer { get; } - public IList RankedEventPicks { get; } - - public ICollection Teams { get; set; } - - public Student(string name, int grade, int tsaYear, string officer, IList rankedEventPicks, - string stateID, string regionalID, string nationalId) - { - Name = name; - Grade = grade; - TsaYear = tsaYear; - Officer = officer; - RankedEventPicks = rankedEventPicks; - StateID=stateID; - RegionalID = regionalID; - NationalID = nationalId; - } - public override string ToString() { return FirstName; } - public bool VotingDelegate => Officer.Contains("Pres"); + public bool VotingDelegate => OfficerRole is Entities.OfficerRole.President or Entities.OfficerRole.VicePresident; + } \ No newline at end of file diff --git a/Core/Entities/StudentEventRanking.cs b/Core/Entities/StudentEventRanking.cs new file mode 100644 index 0000000..ff483e4 --- /dev/null +++ b/Core/Entities/StudentEventRanking.cs @@ -0,0 +1,12 @@ +namespace Core.Entities; + +public class StudentEventRanking +{ + public Student Student { get; set; } = null!; + + public EventDefinition EventDefinition { get; set; } = null!; + public int Rank { get; set; } + + public const int MaxRank = 6; + +} \ No newline at end of file diff --git a/Core/Entities/StudentEventStatistics.cs b/Core/Entities/StudentEventStatistics.cs new file mode 100644 index 0000000..225b549 --- /dev/null +++ b/Core/Entities/StudentEventStatistics.cs @@ -0,0 +1,32 @@ +namespace Core.Entities; + +public class StudentEventStatistics +{ + public Student Student { get; set; } + public List Events { get; set; } = []; + + public int? TotalLevelOfEffort => Events.Sum(e => e.LevelOfEffort); + + public int EventCount => Events.Count; + + public bool HasRegionalEvent => Events.Any(e => e.RegionalEvent); + + public bool HasOnSiteActivity => Events.Any(e => e.OnSiteActivity); + + public static List Generate(ICollection teams) + { + Dictionary statistics = []; + + foreach (var team in teams) + { + foreach (var student in team.Students) + { + if (!statistics.ContainsKey(student)) + statistics.Add(student, new StudentEventStatistics(){Student = student}); + statistics[student].Events.Add(team.Event); + } + } + + return statistics.Values.ToList(); + } +} \ No newline at end of file diff --git a/Core/Entities/Team.cs b/Core/Entities/Team.cs index 59ec5d3..204374c 100644 --- a/Core/Entities/Team.cs +++ b/Core/Entities/Team.cs @@ -1,40 +1,44 @@ -using System.Reflection; -using System.Text.RegularExpressions; +using System.ComponentModel.DataAnnotations; namespace Core.Entities; - public class Team { - public string Name { get; } - public CompetitiveEvent Event { get; } - public IList Students { get; } + public int Id { get; set; } - public Student? Captain { get; set; } + [Required] + [Display(Name = "Team Name")] + public string Name { get; set; } + public EventDefinition Event { get; set; } + public List Students { get; set; } = []; - public string TeamNumber { get; set; } + public Student? Captain { get; set; } - public string RegionalTimeSlot { get; set; } + [Display(Name = "Team Id")] + public string? TeamId { get; set; } - public Tuple? RegionalTimeSlotObj - { - get - { + //public string? RegionalTimeSlot { get; set; } + + + // public Tuple? RegionalTimeSlotObj + //{ + // get + // { - if (string.IsNullOrEmpty(RegionalTimeSlot)) - return null; - var times = Regex.Matches(RegionalTimeSlot, @"(.*)\s*-\s*(.*)"); - if (times.Count == 0) - return Tuple.Create(P(RegionalTimeSlot), (DateTime?)null); - var match = times[0]; - if (!match.Success) - return Tuple.Create(P(RegionalTimeSlot), (DateTime?)null); + // if (string.IsNullOrEmpty(RegionalTimeSlot)) + // return null; + // var times = Regex.Matches(RegionalTimeSlot, @"(.*)\s*-\s*(.*)"); + // if (times.Count == 0) + // return Tuple.Create(P(RegionalTimeSlot), (DateTime?)null); + // var match = times[0]; + // if (!match.Success) + // return Tuple.Create(P(RegionalTimeSlot), (DateTime?)null); - return Tuple.Create(P(match.Groups[1].Value), (DateTime?)P(match.Groups[2].Value)); - return null; - } - } + // return Tuple.Create(P(match.Groups[1].Value), (DateTime?)P(match.Groups[2].Value)); + // return null; + // } + //} - private DateTime P(string s) + private DateTime P(string s) { var dt = DateTime.Parse(s); if (dt.TimeOfDay < TimeSpan.FromHours(7)) @@ -42,28 +46,18 @@ public class Team return dt; } - public Team(string name, CompetitiveEvent @event, IList students, Student? captain = null, string teamNumber = null, string regionalTimeSlot = null) - { - Name = name; - Event = @event; - Students = students; - Captain = captain; - TeamNumber = teamNumber; - RegionalTimeSlot = regionalTimeSlot; - } - public virtual Team CloneWithOmittedStudents(IEnumerable studentsToOmit) { var studentsToOmitList = studentsToOmit.ToList(); var omittedStudents = Students.Where(studentsToOmitList.Contains).ToList(); if (!omittedStudents.Any()) - return new Team(Name, Event, Students.ToList(), Captain); + return new Team{Captain = Captain, Event = Event, Students = Students.ToList(), TeamId = Name}; var remainingStudents = Students.Where(s => !studentsToOmitList.Contains(s)).ToList(); - return new PartialTeam(Name, Event, remainingStudents, omittedStudents); + return new PartialTeam { Name = Name, Event = Event, Students = remainingStudents, OmittedStudents = omittedStudents}; } - public Team Clone() => CloneWithOmittedStudents(Array.Empty()); + public Team Clone() => CloneWithOmittedStudents([]); public static int GetStudentTeamOverlapCount(IList[] timeSlots) { @@ -75,7 +69,7 @@ public class Team return GetStudentTeamOverlaps(timeSlot).Count(); } - public static IEnumerable>> GetStudentTeamOverlaps(IList timeSlot) + public static IEnumerable>> GetStudentTeamOverlaps(IList timeSlot) { return from s in timeSlot.SelectMany(ts => ts.Students).Distinct() @@ -92,7 +86,7 @@ public class Team public string ToStringWithIndividualAndRegional() { - var ind = Event.Format is EventFormat.Individual ? " (Ind.)" : string.Empty; + var ind = Event.EventFormat is EventFormat.Individual ? " (Ind.)" : string.Empty; var regional= Event.RegionalEvent ? " (Reg.)" : string.Empty; //var regional= Event.RegionalEvent ? " (Reg.)" : string.Empty; diff --git a/Core/Parsers/AssignmentAssumptionParser.cs b/Core/Parsers/AssignmentRequirementParser.cs similarity index 57% rename from Core/Parsers/AssignmentAssumptionParser.cs rename to Core/Parsers/AssignmentRequirementParser.cs index dc91728..8a2fc12 100644 --- a/Core/Parsers/AssignmentAssumptionParser.cs +++ b/Core/Parsers/AssignmentRequirementParser.cs @@ -2,15 +2,15 @@ namespace Core.Parsers; -public class AssignmentAssumptionParser : CsvParserBase +public class AssignmentRequirementParser : CsvParserBase { - public AssignmentAssumptionParser(FileSystemInfo csvFile, bool ignoreBlankLines = true) : base(csvFile, ignoreBlankLines) + public AssignmentRequirementParser(FileSystemInfo csvFile, bool ignoreBlankLines = true) : base(csvFile, ignoreBlankLines) { } - public AssignmentAssumption[] Parse(ICollection events, ICollection students) + public AssignmentRequirement[] Parse(ICollection events, ICollection students) { - var assumptions = new List(); + var assumptions = new List(); CsvReader.Read(); CsvReader.ReadHeader(); @@ -27,7 +27,7 @@ public class AssignmentAssumptionParser : CsvParserBase var evt = events.FirstOrDefault(e => e.ShortName == eventShortName); if (evt == null) - throw new Exception($"Could not find event named {eventShortName}"); + throw new Exception($"Could not find eventDefinition named {eventShortName}"); for (int i = 0; i <= studentArray.Length; i++) { var field = CsvReader.GetField(i + 1); @@ -35,11 +35,11 @@ public class AssignmentAssumptionParser : CsvParserBase { case "x": case "X": - assumptions.Add(new AssignmentAssumption(evt, studentArray[i], Assumption.Exclude)); + assumptions.Add(new AssignmentRequirement(evt, studentArray[i], Requirement.Exclude)); break; case "i": case "I": - assumptions.Add(new AssignmentAssumption(evt, studentArray[i], Assumption.Include)); + assumptions.Add(new AssignmentRequirement(evt, studentArray[i], Requirement.Include)); break; default: break; diff --git a/Core/Parsers/CsvParserBase.cs b/Core/Parsers/CsvParserBase.cs index 688ef13..6d9029d 100644 --- a/Core/Parsers/CsvParserBase.cs +++ b/Core/Parsers/CsvParserBase.cs @@ -10,9 +10,13 @@ public class CsvParserBase : IDisposable //private readonly MemoryStream _memoryStream; protected readonly CsvReader CsvReader; - protected CsvParserBase(FileSystemInfo csvFile, bool ignoreBlankLines) + protected CsvParserBase(FileSystemInfo csvFile, bool ignoreBlankLines) : this(OpenCsv(csvFile), ignoreBlankLines) { - _reader = OpenCsv(csvFile); + } + + protected CsvParserBase(StreamReader reader, bool ignoreBlankLines) + { + _reader = reader; CsvReader = InitCsvReader(_reader, ignoreBlankLines); } diff --git a/Core/Parsers/EventDefinitionParser.cs b/Core/Parsers/EventDefinitionParser.cs index 73e509d..5bd70bb 100644 --- a/Core/Parsers/EventDefinitionParser.cs +++ b/Core/Parsers/EventDefinitionParser.cs @@ -9,21 +9,25 @@ public class EventDefinitionParser : CsvParserBase { } - public CompetitiveEvent[] Parse() + public EventDefinitionParser(StreamReader reader, bool ignoreBlankLines = true) : base(reader, ignoreBlankLines) { - var events = new List(); + } + + public EventDefinition[] Parse() + { + var events = new List(); CsvReader.Read(); CsvReader.ReadHeader(); - while (CsvReader.Read()) + while (CsvReader.Read()) { var name = CsvReader.GetField("Event"); if (string.IsNullOrEmpty(name)) continue; var shortName = CsvReader.GetField("Short Name"); - Enum.TryParse(CsvReader.GetField("Format"), out EventFormat format); + Enum.TryParse(CsvReader.GetField("EventFormat"), out EventFormat format); var teamSize = CsvReader.GetField("Team Size"); if (string.IsNullOrEmpty(teamSize)) @@ -47,18 +51,18 @@ public class EventDefinitionParser : CsvParserBase var levelOfEffort = CsvReader.GetField("Level of Effort"); //var regionalTeams = CsvReader.GetField("Regional Teams"); - var competitiveEvent = new CompetitiveEvent + var competitiveEvent = new EventDefinition { Name = name.Trim(), ShortName = shortName.Trim(), - Format = format, + EventFormat = format, MaxTeamCountState = stateTeams, MinTeamSize = min, MaxTeamSize = max, SemifinalistActivity = semifinalistActivity, RegionalEvent = !string.IsNullOrEmpty(regionalCount), RegionalPresubmit = regionalPresubmit.Trim() == "TRUE", - RegionalNotes = regionalNotes, + Notes = regionalNotes, Documentation= documentation, StatePresubmission = statePresubmission.Trim() == "TRUE", StatePretesting = statePretesting.Trim() == "TRUE", diff --git a/Core/Parsers/EventOccurrenceParser.cs b/Core/Parsers/EventOccurrenceParser.cs index 102a866..c7d395b 100644 --- a/Core/Parsers/EventOccurrenceParser.cs +++ b/Core/Parsers/EventOccurrenceParser.cs @@ -7,9 +7,9 @@ namespace Core.Parsers; public class EventOccurrenceParser { private FileSystemInfo _txtFile; - private ICollection _events; + private ICollection _events; - public EventOccurrenceParser(FileSystemInfo txtFile, ICollection events) + public EventOccurrenceParser(FileSystemInfo txtFile, ICollection events) { _events = events; _txtFile = txtFile; @@ -28,10 +28,10 @@ public class EventOccurrenceParser private readonly Regex _timeLocationRegex = new(@"(?