Add comprehensive validation system with runtime configuration

Implements a flexible validation framework for student rankings, team assignments, and team composition with administrator-configurable rules and thresholds.

Validation System:
- Reflection-based rule discovery eliminates manual registration
- Base classes (RequiredEventTypeRuleBase, EventCountThresholdRuleBase) reduce code duplication by ~250 lines
- 12 validation rules covering event requirements, counts, and team constraints
- Configurable severity levels (Warning/Error) per rule type
- ValidationService with caching for optimal performance
- Rules apply contextually (StudentRanking, StudentAssignment, TeamComposition)

Configuration & Admin UI:
- ValidationSettings admin page for editing thresholds and severity levels
- ChapterSettings admin page for editing chapter information
- Settings stored in Data/appsettings.json for runtime configuration
- JSON config works in both development and production environments
- Auto-creates Data directory and config template on first run

User Experience:
- ValidationWarnings component displays inline warnings/errors
- Integrated in Event Ranking, Registration, and Team Assignment pages
- Color-coded severity indicators (warning yellow, error red)
- Includes "Too Many Regional Events" rule (max 3 recommended)

Technical Improvements:
- Template Method pattern for rule base classes
- Singleton rule instances with lazy initialization
- Configuration loaded via IConfiguration with fallback to defaults
- Safe JSON updates preserve other appsettings sections
This commit is contained in:
2025-12-13 22:15:16 -05:00
parent 215b9dccca
commit 1c7e704ad3
36 changed files with 1839 additions and 83 deletions
@@ -0,0 +1,40 @@
using Core.Entities;
namespace Core.Validation.Rules.TeamRules;
/// <summary>
/// Validation rule that checks if a team-based event has an assigned captain
/// </summary>
public class MissingCaptainRule : IValidationRule<Team>
{
public ValidationWarning? Validate(Team entity, ValidationConfiguration config)
{
if (!config.RequireTeamCaptain)
return null;
// Individual events don't need captains
if (entity.Event.EventFormat == EventFormat.Individual)
return null;
if (entity.Captain != null)
return null;
return new ValidationWarning
{
Code = "MISSING_CAPTAIN",
Message = "Team has no captain assigned",
Severity = config.MissingCaptainSeverity,
Context = ValidationContext.Team,
IconIdentifier = "Captain",
Metadata = new Dictionary<string, object>
{
{ "TeamId", entity.Id },
{ "EventName", entity.Event.Name },
{ "TeamSize", entity.Students.Count }
}
};
}
public bool AppliesTo(ValidationContext context) =>
context == ValidationContext.Team;
}
@@ -0,0 +1,42 @@
using Core.Entities;
namespace Core.Validation.Rules.TeamRules;
/// <summary>
/// Validation rule that checks if a team has more members than the maximum allowed
/// </summary>
public class TeamSizeTooLargeRule : IValidationRule<Team>
{
public ValidationWarning? Validate(Team entity, ValidationConfiguration config)
{
// Individual events don't have team size requirements
if (entity.Event.EventFormat == EventFormat.Individual)
return null;
var actualSize = entity.Students.Count;
var maxSize = entity.Event.MaxTeamSize;
if (actualSize <= maxSize)
return null;
return new ValidationWarning
{
Code = "TEAM_SIZE_TOO_LARGE",
Message = $"Team has {actualSize} members (max: {maxSize})",
Severity = ValidationSeverity.Error, // Always error - hard constraint
Context = ValidationContext.Team,
IconIdentifier = null,
Metadata = new Dictionary<string, object>
{
{ "TeamId", entity.Id },
{ "EventName", entity.Event.Name },
{ "ActualSize", actualSize },
{ "MinSize", entity.Event.MinTeamSize },
{ "MaxSize", maxSize }
}
};
}
public bool AppliesTo(ValidationContext context) =>
context == ValidationContext.Team;
}
@@ -0,0 +1,42 @@
using Core.Entities;
namespace Core.Validation.Rules.TeamRules;
/// <summary>
/// Validation rule that checks if a team has fewer members than the minimum required
/// </summary>
public class TeamSizeTooSmallRule : IValidationRule<Team>
{
public ValidationWarning? Validate(Team entity, ValidationConfiguration config)
{
// Individual events don't have team size requirements
if (entity.Event.EventFormat == EventFormat.Individual)
return null;
var actualSize = entity.Students.Count;
var minSize = entity.Event.MinTeamSize;
if (actualSize >= minSize)
return null;
return new ValidationWarning
{
Code = "TEAM_SIZE_TOO_SMALL",
Message = $"Team has {actualSize} members (min: {minSize})",
Severity = config.TeamSizeSeverity,
Context = ValidationContext.Team,
IconIdentifier = null,
Metadata = new Dictionary<string, object>
{
{ "TeamId", entity.Id },
{ "EventName", entity.Event.Name },
{ "ActualSize", actualSize },
{ "MinSize", minSize },
{ "MaxSize", entity.Event.MaxTeamSize }
}
};
}
public bool AppliesTo(ValidationContext context) =>
context == ValidationContext.Team;
}