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,88 @@
using Core.Entities;
namespace Core.Validation.Rules.BaseRules;
/// <summary>
/// Base class for validation rules that check event counts against thresholds for student rankings
/// Similar to EventCountThresholdRuleBase but works with Student entities instead of StudentEventStatistics
/// </summary>
public abstract class EventCountThresholdRankingRuleBase : IValidationRule<Student>
{
/// <summary>
/// Get the actual count to validate
/// </summary>
protected abstract int GetCount(Student student);
/// <summary>
/// Get the threshold value from configuration
/// </summary>
protected abstract int GetThreshold(ValidationConfiguration config);
/// <summary>
/// Check if the count violates the threshold
/// </summary>
protected abstract bool ViolatesThreshold(int count, int threshold);
/// <summary>
/// Get the severity from configuration
/// </summary>
protected abstract ValidationSeverity GetSeverity(ValidationConfiguration config);
/// <summary>
/// The validation warning code
/// </summary>
protected abstract string Code { get; }
/// <summary>
/// Build the display message
/// </summary>
protected abstract string GetMessage(int count, int threshold);
/// <summary>
/// Icon identifier for the warning (can be null)
/// </summary>
protected virtual string? IconIdentifier => null;
/// <summary>
/// Build additional metadata beyond the standard fields
/// </summary>
protected virtual Dictionary<string, object> BuildAdditionalMetadata(Student student, ValidationConfiguration config)
{
return new Dictionary<string, object>();
}
public ValidationWarning? Validate(Student entity, ValidationConfiguration config)
{
var count = GetCount(entity);
var threshold = GetThreshold(config);
if (!ViolatesThreshold(count, threshold))
return null;
var metadata = new Dictionary<string, object>
{
{ "StudentId", entity.Id },
{ "StudentName", entity.FirstNameLastName }
};
// Add any additional metadata from derived class
foreach (var kvp in BuildAdditionalMetadata(entity, config))
{
metadata[kvp.Key] = kvp.Value;
}
return new ValidationWarning
{
Code = Code,
Message = GetMessage(count, threshold),
Severity = GetSeverity(config),
Context = ValidationContext.StudentRanking,
IconIdentifier = IconIdentifier,
Metadata = metadata
};
}
public bool AppliesTo(ValidationContext context) =>
context == ValidationContext.StudentRanking ||
context == ValidationContext.StudentRegistration;
}
@@ -0,0 +1,108 @@
using Core.Entities;
namespace Core.Validation.Rules.BaseRules;
/// <summary>
/// Base class for validation rules that check event counts against thresholds
/// Eliminates duplication across TooManyEvents, TooFewEvents, and TooManyRegionalEvents rules
/// </summary>
public abstract class EventCountThresholdRuleBase : IValidationRule<StudentEventStatistics>
{
/// <summary>
/// Get the actual count to validate
/// </summary>
protected abstract int GetCount(StudentEventStatistics statistics);
/// <summary>
/// Get the threshold value from configuration
/// </summary>
protected abstract int GetThreshold(ValidationConfiguration config);
/// <summary>
/// Check if the count violates the threshold
/// </summary>
protected abstract bool ViolatesThreshold(int count, int threshold);
/// <summary>
/// Get the base severity from configuration (can be overridden for critical thresholds)
/// </summary>
protected abstract ValidationSeverity GetBaseSeverity(ValidationConfiguration config);
/// <summary>
/// Optionally check for critical threshold that escalates to Error severity
/// Returns null if no critical threshold applies
/// </summary>
protected virtual int? GetCriticalThreshold(ValidationConfiguration config) => null;
/// <summary>
/// Check if count violates critical threshold (for escalation to Error)
/// </summary>
protected virtual bool ViolatesCriticalThreshold(int count, int criticalThreshold) => false;
/// <summary>
/// The validation warning code
/// </summary>
protected abstract string Code { get; }
/// <summary>
/// Build the display message
/// </summary>
protected abstract string GetMessage(int count, int threshold);
/// <summary>
/// Icon identifier for the warning (can be null)
/// </summary>
protected virtual string? IconIdentifier => null;
/// <summary>
/// Build additional metadata beyond the standard fields
/// </summary>
protected virtual Dictionary<string, object> BuildAdditionalMetadata(StudentEventStatistics statistics, ValidationConfiguration config)
{
return new Dictionary<string, object>();
}
public ValidationWarning? Validate(StudentEventStatistics entity, ValidationConfiguration config)
{
var count = GetCount(entity);
var threshold = GetThreshold(config);
if (!ViolatesThreshold(count, threshold))
return null;
// Check for critical threshold escalation
var severity = GetBaseSeverity(config);
var criticalThreshold = GetCriticalThreshold(config);
if (criticalThreshold.HasValue && ViolatesCriticalThreshold(count, criticalThreshold.Value))
{
severity = ValidationSeverity.Error;
}
var metadata = new Dictionary<string, object>
{
{ "StudentId", entity.Student.Id },
{ "StudentName", entity.Student.FirstNameLastName },
{ "EventCount", entity.EventCount }
};
// Add any additional metadata from derived class
foreach (var kvp in BuildAdditionalMetadata(entity, config))
{
metadata[kvp.Key] = kvp.Value;
}
return new ValidationWarning
{
Code = Code,
Message = GetMessage(count, threshold),
Severity = severity,
Context = ValidationContext.StudentAssignment,
IconIdentifier = IconIdentifier,
Metadata = metadata
};
}
public bool AppliesTo(ValidationContext context) =>
context == ValidationContext.StudentAssignment ||
context == ValidationContext.StudentRegistration;
}
@@ -0,0 +1,68 @@
using Core.Entities;
namespace Core.Validation.Rules.BaseRules;
/// <summary>
/// Base class for validation rules that check if a student has been assigned a required event type
/// Eliminates duplication across assignment validation rules
/// </summary>
public abstract class RequiredEventTypeAssignmentRuleBase : IValidationRule<StudentEventStatistics>
{
/// <summary>
/// Check if this event type is required based on configuration
/// </summary>
protected abstract bool IsRequired(ValidationConfiguration config);
/// <summary>
/// Check if the student has been assigned this event type
/// </summary>
protected abstract bool HasEventType(StudentEventStatistics statistics);
/// <summary>
/// Get the severity level for this rule from configuration
/// </summary>
protected abstract ValidationSeverity GetSeverity(ValidationConfiguration config);
/// <summary>
/// The validation warning code (e.g., "NO_REGIONAL_EVENT_ASSIGNED")
/// </summary>
protected abstract string Code { get; }
/// <summary>
/// The display message for the warning
/// </summary>
protected abstract string Message { get; }
/// <summary>
/// Icon identifier for the warning (e.g., "RegionalEvent")
/// </summary>
protected abstract string IconIdentifier { get; }
public ValidationWarning? Validate(StudentEventStatistics entity, ValidationConfiguration config)
{
if (!IsRequired(config))
return null;
if (HasEventType(entity))
return null;
return new ValidationWarning
{
Code = Code,
Message = Message,
Severity = GetSeverity(config),
Context = ValidationContext.StudentAssignment,
IconIdentifier = IconIdentifier,
Metadata = new Dictionary<string, object>
{
{ "StudentId", entity.Student.Id },
{ "StudentName", entity.Student.FirstNameLastName },
{ "EventCount", entity.EventCount }
}
};
}
public bool AppliesTo(ValidationContext context) =>
context == ValidationContext.StudentAssignment ||
context == ValidationContext.StudentRegistration;
}
@@ -0,0 +1,67 @@
using Core.Entities;
namespace Core.Validation.Rules.BaseRules;
/// <summary>
/// Base class for validation rules that check if a student has ranked a required event type
/// Eliminates duplication across NoRegionalEvent, NoOnSiteActivity, and NoIndividualEvent rules
/// </summary>
public abstract class RequiredEventTypeRuleBase : IValidationRule<Student>
{
/// <summary>
/// Check if this event type is required based on configuration
/// </summary>
protected abstract bool IsRequired(ValidationConfiguration config);
/// <summary>
/// Check if the student has ranked this event type
/// </summary>
protected abstract bool HasEventType(Student student);
/// <summary>
/// Get the severity level for this rule from configuration
/// </summary>
protected abstract ValidationSeverity GetSeverity(ValidationConfiguration config);
/// <summary>
/// The validation warning code (e.g., "NO_REGIONAL_EVENT")
/// </summary>
protected abstract string Code { get; }
/// <summary>
/// The display message for the warning
/// </summary>
protected abstract string Message { get; }
/// <summary>
/// Icon identifier for the warning (e.g., "RegionalEvent")
/// </summary>
protected abstract string IconIdentifier { get; }
public ValidationWarning? Validate(Student entity, ValidationConfiguration config)
{
if (!IsRequired(config))
return null;
if (HasEventType(entity))
return null;
return new ValidationWarning
{
Code = Code,
Message = Message,
Severity = GetSeverity(config),
Context = ValidationContext.StudentRanking,
IconIdentifier = IconIdentifier,
Metadata = new Dictionary<string, object>
{
{ "StudentId", entity.Id },
{ "StudentName", entity.FirstNameLastName }
}
};
}
public bool AppliesTo(ValidationContext context) =>
context == ValidationContext.StudentRanking ||
context == ValidationContext.StudentRegistration;
}