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;
}
@@ -0,0 +1,22 @@
using Core.Entities;
using Core.Validation.Rules.BaseRules;
namespace Core.Validation.Rules.StudentAssignmentRules;
/// <summary>
/// Validation rule that checks if a student has been assigned at least one on-site activity
/// </summary>
public class NoOnSiteActivityAssignmentRule : RequiredEventTypeAssignmentRuleBase
{
protected override bool IsRequired(ValidationConfiguration config) => config.RequireOnSiteActivity;
protected override bool HasEventType(StudentEventStatistics statistics) => statistics.HasOnSiteActivity;
protected override ValidationSeverity GetSeverity(ValidationConfiguration config) => config.NoOnSiteActivitySeverity;
protected override string Code => "NO_ONSITE_ACTIVITY_ASSIGNED";
protected override string Message => "No On-Site Activity";
protected override string IconIdentifier => "OnSiteActivity";
}
@@ -0,0 +1,22 @@
using Core.Entities;
using Core.Validation.Rules.BaseRules;
namespace Core.Validation.Rules.StudentAssignmentRules;
/// <summary>
/// Validation rule that checks if a student has been assigned at least one regional event
/// </summary>
public class NoRegionalEventAssignmentRule : RequiredEventTypeAssignmentRuleBase
{
protected override bool IsRequired(ValidationConfiguration config) => config.RequireRegionalEvent;
protected override bool HasEventType(StudentEventStatistics statistics) => statistics.HasRegionalEvent;
protected override ValidationSeverity GetSeverity(ValidationConfiguration config) => config.NoRegionalEventSeverity;
protected override string Code => "NO_REGIONAL_EVENT_ASSIGNED";
protected override string Message => "No Regional Event";
protected override string IconIdentifier => "RegionalEvent";
}
@@ -0,0 +1,36 @@
using Core.Entities;
using Core.Validation.Rules.BaseRules;
namespace Core.Validation.Rules.StudentAssignmentRules;
/// <summary>
/// Validation rule that checks if a student has too few event assignments
/// </summary>
public class TooFewEventsRule : EventCountThresholdRuleBase
{
protected override int GetCount(StudentEventStatistics statistics) => statistics.EventCount;
protected override int GetThreshold(ValidationConfiguration config) => config.MinRecommendedEvents;
protected override bool ViolatesThreshold(int count, int threshold) => count < threshold;
protected override ValidationSeverity GetBaseSeverity(ValidationConfiguration config) => config.EventCountSeverity;
protected override int? GetCriticalThreshold(ValidationConfiguration config) => config.MinCriticalEvents;
protected override bool ViolatesCriticalThreshold(int count, int criticalThreshold) => count < criticalThreshold;
protected override string Code => "TOO_FEW_EVENTS";
protected override string GetMessage(int count, int threshold) =>
$"Student has {count} events (min recommended: {threshold})";
protected override Dictionary<string, object> BuildAdditionalMetadata(StudentEventStatistics statistics, ValidationConfiguration config)
{
return new Dictionary<string, object>
{
{ "MinRecommended", config.MinRecommendedEvents },
{ "MinCritical", config.MinCriticalEvents }
};
}
}
@@ -0,0 +1,36 @@
using Core.Entities;
using Core.Validation.Rules.BaseRules;
namespace Core.Validation.Rules.StudentAssignmentRules;
/// <summary>
/// Validation rule that checks if a student has too many event assignments
/// </summary>
public class TooManyEventsRule : EventCountThresholdRuleBase
{
protected override int GetCount(StudentEventStatistics statistics) => statistics.EventCount;
protected override int GetThreshold(ValidationConfiguration config) => config.MaxRecommendedEvents;
protected override bool ViolatesThreshold(int count, int threshold) => count > threshold;
protected override ValidationSeverity GetBaseSeverity(ValidationConfiguration config) => config.EventCountSeverity;
protected override int? GetCriticalThreshold(ValidationConfiguration config) => config.MaxCriticalEvents;
protected override bool ViolatesCriticalThreshold(int count, int criticalThreshold) => count > criticalThreshold;
protected override string Code => "TOO_MANY_EVENTS";
protected override string GetMessage(int count, int threshold) =>
$"Student has {count} events (max recommended: {threshold})";
protected override Dictionary<string, object> BuildAdditionalMetadata(StudentEventStatistics statistics, ValidationConfiguration config)
{
return new Dictionary<string, object>
{
{ "MaxRecommended", config.MaxRecommendedEvents },
{ "MaxCritical", config.MaxCriticalEvents }
};
}
}
@@ -0,0 +1,36 @@
using Core.Entities;
using Core.Validation.Rules.BaseRules;
namespace Core.Validation.Rules.StudentAssignmentRules;
/// <summary>
/// Validation rule that checks if a student has too many regional events in their assignments
/// </summary>
public class TooManyRegionalEventsAssignmentRule : EventCountThresholdRuleBase
{
protected override int GetCount(StudentEventStatistics statistics) =>
statistics.Events.Count(e => e.RegionalEvent);
protected override int GetThreshold(ValidationConfiguration config) => config.MaxRegionalEvents;
protected override bool ViolatesThreshold(int count, int threshold) => count > threshold;
protected override ValidationSeverity GetBaseSeverity(ValidationConfiguration config) =>
config.TooManyRegionalEventsSeverity;
protected override string Code => "TOO_MANY_REGIONAL_EVENTS";
protected override string GetMessage(int count, int threshold) =>
$"Student has {count} regional events (max recommended: {threshold})";
protected override string? IconIdentifier => "RegionalEvent";
protected override Dictionary<string, object> BuildAdditionalMetadata(StudentEventStatistics statistics, ValidationConfiguration config)
{
return new Dictionary<string, object>
{
{ "RegionalEventCount", GetCount(statistics) },
{ "MaxRegionalEvents", config.MaxRegionalEvents }
};
}
}
@@ -0,0 +1,22 @@
using Core.Entities;
using Core.Validation.Rules.BaseRules;
namespace Core.Validation.Rules.StudentRankingRules;
/// <summary>
/// Validation rule that checks if a student has ranked at least one individual event
/// </summary>
public class NoIndividualEventRule : RequiredEventTypeRuleBase
{
protected override bool IsRequired(ValidationConfiguration config) => config.RequireIndividualEvent;
protected override bool HasEventType(Student student) => student.RankedEvents.Any(e => e.EventFormat == EventFormat.Individual);
protected override ValidationSeverity GetSeverity(ValidationConfiguration config) => config.NoIndividualEventSeverity;
protected override string Code => "NO_INDIVIDUAL_EVENT";
protected override string Message => "No Individual Event";
protected override string IconIdentifier => "IndividualEvent";
}
@@ -0,0 +1,22 @@
using Core.Entities;
using Core.Validation.Rules.BaseRules;
namespace Core.Validation.Rules.StudentRankingRules;
/// <summary>
/// Validation rule that checks if a student has ranked at least one on-site activity
/// </summary>
public class NoOnSiteActivityRule : RequiredEventTypeRuleBase
{
protected override bool IsRequired(ValidationConfiguration config) => config.RequireOnSiteActivity;
protected override bool HasEventType(Student student) => student.RankedEvents.Any(e => e.OnSiteActivity);
protected override ValidationSeverity GetSeverity(ValidationConfiguration config) => config.NoOnSiteActivitySeverity;
protected override string Code => "NO_ONSITE_ACTIVITY";
protected override string Message => "No On-Site Activity";
protected override string IconIdentifier => "OnSiteActivity";
}
@@ -0,0 +1,22 @@
using Core.Entities;
using Core.Validation.Rules.BaseRules;
namespace Core.Validation.Rules.StudentRankingRules;
/// <summary>
/// Validation rule that checks if a student has ranked at least one regional event
/// </summary>
public class NoRegionalEventRule : RequiredEventTypeRuleBase
{
protected override bool IsRequired(ValidationConfiguration config) => config.RequireRegionalEvent;
protected override bool HasEventType(Student student) => student.RankedEvents.Any(e => e.RegionalEvent);
protected override ValidationSeverity GetSeverity(ValidationConfiguration config) => config.NoRegionalEventSeverity;
protected override string Code => "NO_REGIONAL_EVENT";
protected override string Message => "No Regional Event";
protected override string IconIdentifier => "RegionalEvent";
}
@@ -0,0 +1,36 @@
using Core.Entities;
using Core.Validation.Rules.BaseRules;
namespace Core.Validation.Rules.StudentRankingRules;
/// <summary>
/// Validation rule that checks if a student has too many regional events in their rankings
/// </summary>
public class TooManyRegionalEventsRule : EventCountThresholdRankingRuleBase
{
protected override int GetCount(Student student) =>
student.RankedEvents.Count(e => e.RegionalEvent);
protected override int GetThreshold(ValidationConfiguration config) => config.MaxRegionalEvents;
protected override bool ViolatesThreshold(int count, int threshold) => count > threshold;
protected override ValidationSeverity GetSeverity(ValidationConfiguration config) =>
config.TooManyRegionalEventsSeverity;
protected override string Code => "TOO_MANY_REGIONAL_EVENTS";
protected override string GetMessage(int count, int threshold) =>
$"Student has {count} regional events (max recommended: {threshold})";
protected override string? IconIdentifier => "RegionalEvent";
protected override Dictionary<string, object> BuildAdditionalMetadata(Student student, ValidationConfiguration config)
{
return new Dictionary<string, object>
{
{ "RegionalEventCount", GetCount(student) },
{ "MaxRegionalEvents", config.MaxRegionalEvents }
};
}
}
@@ -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;
}