From 1c7e704ad32116ecacf44f030536da57bdae1f6d Mon Sep 17 00:00:00 2001 From: James Kolpack Date: Sat, 13 Dec 2025 22:15:16 -0500 Subject: [PATCH] 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 --- Core/Validation/IValidationRule.cs | 23 ++ .../EventCountThresholdRankingRuleBase.cs | 88 ++++++ .../BaseRules/EventCountThresholdRuleBase.cs | 108 +++++++ .../RequiredEventTypeAssignmentRuleBase.cs | 68 +++++ .../BaseRules/RequiredEventTypeRuleBase.cs | 67 +++++ .../NoOnSiteActivityAssignmentRule.cs | 22 ++ .../NoRegionalEventAssignmentRule.cs | 22 ++ .../TooFewEventsRule.cs | 36 +++ .../TooManyEventsRule.cs | 36 +++ .../TooManyRegionalEventsAssignmentRule.cs | 36 +++ .../NoIndividualEventRule.cs | 22 ++ .../NoOnSiteActivityRule.cs | 22 ++ .../NoRegionalEventRule.cs | 22 ++ .../TooManyRegionalEventsRule.cs | 36 +++ .../Rules/TeamRules/MissingCaptainRule.cs | 40 +++ .../Rules/TeamRules/TeamSizeTooLargeRule.cs | 42 +++ .../Rules/TeamRules/TeamSizeTooSmallRule.cs | 42 +++ Core/Validation/ValidationConfiguration.cs | 192 ++++++++++++ Core/Validation/ValidationContext.cs | 27 ++ Core/Validation/ValidationService.cs | 130 ++++++++ Core/Validation/ValidationSeverity.cs | 17 ++ Core/Validation/ValidationWarning.cs | 37 +++ .../Events/Components/EventAttributes.razor | 10 +- .../Features/Students/EventRanking.razor | 27 +- .../Features/Students/Registration.razor | 21 ++ .../Features/Teams/Assignment.razor | 29 +- WebApp/Components/Pages/ChapterSettings.razor | 186 ++++++++++++ WebApp/Components/Pages/Home.razor | 21 +- .../Components/Pages/ValidationSettings.razor | 284 ++++++++++++++++++ WebApp/Components/Shared/Layout/NavMenu.razor | 6 + .../Shared/ValidationWarnings.razor | 37 +++ WebApp/Models/AppIcons.cs | 6 +- WebApp/Models/ChapterSettings.cs | 37 +++ WebApp/Program.cs | 93 +++--- WebApp/appsettings.json | 18 ++ WebApp/wwwroot/app.css | 12 + 36 files changed, 1839 insertions(+), 83 deletions(-) create mode 100644 Core/Validation/IValidationRule.cs create mode 100644 Core/Validation/Rules/BaseRules/EventCountThresholdRankingRuleBase.cs create mode 100644 Core/Validation/Rules/BaseRules/EventCountThresholdRuleBase.cs create mode 100644 Core/Validation/Rules/BaseRules/RequiredEventTypeAssignmentRuleBase.cs create mode 100644 Core/Validation/Rules/BaseRules/RequiredEventTypeRuleBase.cs create mode 100644 Core/Validation/Rules/StudentAssignmentRules/NoOnSiteActivityAssignmentRule.cs create mode 100644 Core/Validation/Rules/StudentAssignmentRules/NoRegionalEventAssignmentRule.cs create mode 100644 Core/Validation/Rules/StudentAssignmentRules/TooFewEventsRule.cs create mode 100644 Core/Validation/Rules/StudentAssignmentRules/TooManyEventsRule.cs create mode 100644 Core/Validation/Rules/StudentAssignmentRules/TooManyRegionalEventsAssignmentRule.cs create mode 100644 Core/Validation/Rules/StudentRankingRules/NoIndividualEventRule.cs create mode 100644 Core/Validation/Rules/StudentRankingRules/NoOnSiteActivityRule.cs create mode 100644 Core/Validation/Rules/StudentRankingRules/NoRegionalEventRule.cs create mode 100644 Core/Validation/Rules/StudentRankingRules/TooManyRegionalEventsRule.cs create mode 100644 Core/Validation/Rules/TeamRules/MissingCaptainRule.cs create mode 100644 Core/Validation/Rules/TeamRules/TeamSizeTooLargeRule.cs create mode 100644 Core/Validation/Rules/TeamRules/TeamSizeTooSmallRule.cs create mode 100644 Core/Validation/ValidationConfiguration.cs create mode 100644 Core/Validation/ValidationContext.cs create mode 100644 Core/Validation/ValidationService.cs create mode 100644 Core/Validation/ValidationSeverity.cs create mode 100644 Core/Validation/ValidationWarning.cs create mode 100644 WebApp/Components/Pages/ChapterSettings.razor create mode 100644 WebApp/Components/Pages/ValidationSettings.razor create mode 100644 WebApp/Components/Shared/ValidationWarnings.razor create mode 100644 WebApp/Models/ChapterSettings.cs diff --git a/Core/Validation/IValidationRule.cs b/Core/Validation/IValidationRule.cs new file mode 100644 index 0000000..c3f26df --- /dev/null +++ b/Core/Validation/IValidationRule.cs @@ -0,0 +1,23 @@ +namespace Core.Validation; + +/// +/// Interface for all validation rules +/// +/// Type of entity being validated (Student, Team, StudentEventStatistics, etc.) +public interface IValidationRule +{ + /// + /// Execute the validation rule against an entity + /// + /// Entity to validate + /// Validation configuration + /// Validation warning if rule is violated, null otherwise + ValidationWarning? Validate(TEntity entity, ValidationConfiguration config); + + /// + /// Determines if this rule applies to the given context + /// + /// Validation context + /// True if the rule should be executed in this context + bool AppliesTo(ValidationContext context); +} diff --git a/Core/Validation/Rules/BaseRules/EventCountThresholdRankingRuleBase.cs b/Core/Validation/Rules/BaseRules/EventCountThresholdRankingRuleBase.cs new file mode 100644 index 0000000..fdfd0b5 --- /dev/null +++ b/Core/Validation/Rules/BaseRules/EventCountThresholdRankingRuleBase.cs @@ -0,0 +1,88 @@ +using Core.Entities; + +namespace Core.Validation.Rules.BaseRules; + +/// +/// 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 +/// +public abstract class EventCountThresholdRankingRuleBase : IValidationRule +{ + /// + /// Get the actual count to validate + /// + protected abstract int GetCount(Student student); + + /// + /// Get the threshold value from configuration + /// + protected abstract int GetThreshold(ValidationConfiguration config); + + /// + /// Check if the count violates the threshold + /// + protected abstract bool ViolatesThreshold(int count, int threshold); + + /// + /// Get the severity from configuration + /// + protected abstract ValidationSeverity GetSeverity(ValidationConfiguration config); + + /// + /// The validation warning code + /// + protected abstract string Code { get; } + + /// + /// Build the display message + /// + protected abstract string GetMessage(int count, int threshold); + + /// + /// Icon identifier for the warning (can be null) + /// + protected virtual string? IconIdentifier => null; + + /// + /// Build additional metadata beyond the standard fields + /// + protected virtual Dictionary BuildAdditionalMetadata(Student student, ValidationConfiguration config) + { + return new Dictionary(); + } + + 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 + { + { "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; +} diff --git a/Core/Validation/Rules/BaseRules/EventCountThresholdRuleBase.cs b/Core/Validation/Rules/BaseRules/EventCountThresholdRuleBase.cs new file mode 100644 index 0000000..42eab42 --- /dev/null +++ b/Core/Validation/Rules/BaseRules/EventCountThresholdRuleBase.cs @@ -0,0 +1,108 @@ +using Core.Entities; + +namespace Core.Validation.Rules.BaseRules; + +/// +/// Base class for validation rules that check event counts against thresholds +/// Eliminates duplication across TooManyEvents, TooFewEvents, and TooManyRegionalEvents rules +/// +public abstract class EventCountThresholdRuleBase : IValidationRule +{ + /// + /// Get the actual count to validate + /// + protected abstract int GetCount(StudentEventStatistics statistics); + + /// + /// Get the threshold value from configuration + /// + protected abstract int GetThreshold(ValidationConfiguration config); + + /// + /// Check if the count violates the threshold + /// + protected abstract bool ViolatesThreshold(int count, int threshold); + + /// + /// Get the base severity from configuration (can be overridden for critical thresholds) + /// + protected abstract ValidationSeverity GetBaseSeverity(ValidationConfiguration config); + + /// + /// Optionally check for critical threshold that escalates to Error severity + /// Returns null if no critical threshold applies + /// + protected virtual int? GetCriticalThreshold(ValidationConfiguration config) => null; + + /// + /// Check if count violates critical threshold (for escalation to Error) + /// + protected virtual bool ViolatesCriticalThreshold(int count, int criticalThreshold) => false; + + /// + /// The validation warning code + /// + protected abstract string Code { get; } + + /// + /// Build the display message + /// + protected abstract string GetMessage(int count, int threshold); + + /// + /// Icon identifier for the warning (can be null) + /// + protected virtual string? IconIdentifier => null; + + /// + /// Build additional metadata beyond the standard fields + /// + protected virtual Dictionary BuildAdditionalMetadata(StudentEventStatistics statistics, ValidationConfiguration config) + { + return new Dictionary(); + } + + 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 + { + { "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; +} diff --git a/Core/Validation/Rules/BaseRules/RequiredEventTypeAssignmentRuleBase.cs b/Core/Validation/Rules/BaseRules/RequiredEventTypeAssignmentRuleBase.cs new file mode 100644 index 0000000..a4d3f27 --- /dev/null +++ b/Core/Validation/Rules/BaseRules/RequiredEventTypeAssignmentRuleBase.cs @@ -0,0 +1,68 @@ +using Core.Entities; + +namespace Core.Validation.Rules.BaseRules; + +/// +/// Base class for validation rules that check if a student has been assigned a required event type +/// Eliminates duplication across assignment validation rules +/// +public abstract class RequiredEventTypeAssignmentRuleBase : IValidationRule +{ + /// + /// Check if this event type is required based on configuration + /// + protected abstract bool IsRequired(ValidationConfiguration config); + + /// + /// Check if the student has been assigned this event type + /// + protected abstract bool HasEventType(StudentEventStatistics statistics); + + /// + /// Get the severity level for this rule from configuration + /// + protected abstract ValidationSeverity GetSeverity(ValidationConfiguration config); + + /// + /// The validation warning code (e.g., "NO_REGIONAL_EVENT_ASSIGNED") + /// + protected abstract string Code { get; } + + /// + /// The display message for the warning + /// + protected abstract string Message { get; } + + /// + /// Icon identifier for the warning (e.g., "RegionalEvent") + /// + 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 + { + { "StudentId", entity.Student.Id }, + { "StudentName", entity.Student.FirstNameLastName }, + { "EventCount", entity.EventCount } + } + }; + } + + public bool AppliesTo(ValidationContext context) => + context == ValidationContext.StudentAssignment || + context == ValidationContext.StudentRegistration; +} diff --git a/Core/Validation/Rules/BaseRules/RequiredEventTypeRuleBase.cs b/Core/Validation/Rules/BaseRules/RequiredEventTypeRuleBase.cs new file mode 100644 index 0000000..f1fe44b --- /dev/null +++ b/Core/Validation/Rules/BaseRules/RequiredEventTypeRuleBase.cs @@ -0,0 +1,67 @@ +using Core.Entities; + +namespace Core.Validation.Rules.BaseRules; + +/// +/// Base class for validation rules that check if a student has ranked a required event type +/// Eliminates duplication across NoRegionalEvent, NoOnSiteActivity, and NoIndividualEvent rules +/// +public abstract class RequiredEventTypeRuleBase : IValidationRule +{ + /// + /// Check if this event type is required based on configuration + /// + protected abstract bool IsRequired(ValidationConfiguration config); + + /// + /// Check if the student has ranked this event type + /// + protected abstract bool HasEventType(Student student); + + /// + /// Get the severity level for this rule from configuration + /// + protected abstract ValidationSeverity GetSeverity(ValidationConfiguration config); + + /// + /// The validation warning code (e.g., "NO_REGIONAL_EVENT") + /// + protected abstract string Code { get; } + + /// + /// The display message for the warning + /// + protected abstract string Message { get; } + + /// + /// Icon identifier for the warning (e.g., "RegionalEvent") + /// + 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 + { + { "StudentId", entity.Id }, + { "StudentName", entity.FirstNameLastName } + } + }; + } + + public bool AppliesTo(ValidationContext context) => + context == ValidationContext.StudentRanking || + context == ValidationContext.StudentRegistration; +} diff --git a/Core/Validation/Rules/StudentAssignmentRules/NoOnSiteActivityAssignmentRule.cs b/Core/Validation/Rules/StudentAssignmentRules/NoOnSiteActivityAssignmentRule.cs new file mode 100644 index 0000000..556ed6c --- /dev/null +++ b/Core/Validation/Rules/StudentAssignmentRules/NoOnSiteActivityAssignmentRule.cs @@ -0,0 +1,22 @@ +using Core.Entities; +using Core.Validation.Rules.BaseRules; + +namespace Core.Validation.Rules.StudentAssignmentRules; + +/// +/// Validation rule that checks if a student has been assigned at least one on-site activity +/// +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"; +} diff --git a/Core/Validation/Rules/StudentAssignmentRules/NoRegionalEventAssignmentRule.cs b/Core/Validation/Rules/StudentAssignmentRules/NoRegionalEventAssignmentRule.cs new file mode 100644 index 0000000..0e44408 --- /dev/null +++ b/Core/Validation/Rules/StudentAssignmentRules/NoRegionalEventAssignmentRule.cs @@ -0,0 +1,22 @@ +using Core.Entities; +using Core.Validation.Rules.BaseRules; + +namespace Core.Validation.Rules.StudentAssignmentRules; + +/// +/// Validation rule that checks if a student has been assigned at least one regional event +/// +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"; +} diff --git a/Core/Validation/Rules/StudentAssignmentRules/TooFewEventsRule.cs b/Core/Validation/Rules/StudentAssignmentRules/TooFewEventsRule.cs new file mode 100644 index 0000000..a86e1bb --- /dev/null +++ b/Core/Validation/Rules/StudentAssignmentRules/TooFewEventsRule.cs @@ -0,0 +1,36 @@ +using Core.Entities; +using Core.Validation.Rules.BaseRules; + +namespace Core.Validation.Rules.StudentAssignmentRules; + +/// +/// Validation rule that checks if a student has too few event assignments +/// +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 BuildAdditionalMetadata(StudentEventStatistics statistics, ValidationConfiguration config) + { + return new Dictionary + { + { "MinRecommended", config.MinRecommendedEvents }, + { "MinCritical", config.MinCriticalEvents } + }; + } +} diff --git a/Core/Validation/Rules/StudentAssignmentRules/TooManyEventsRule.cs b/Core/Validation/Rules/StudentAssignmentRules/TooManyEventsRule.cs new file mode 100644 index 0000000..6b32b3b --- /dev/null +++ b/Core/Validation/Rules/StudentAssignmentRules/TooManyEventsRule.cs @@ -0,0 +1,36 @@ +using Core.Entities; +using Core.Validation.Rules.BaseRules; + +namespace Core.Validation.Rules.StudentAssignmentRules; + +/// +/// Validation rule that checks if a student has too many event assignments +/// +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 BuildAdditionalMetadata(StudentEventStatistics statistics, ValidationConfiguration config) + { + return new Dictionary + { + { "MaxRecommended", config.MaxRecommendedEvents }, + { "MaxCritical", config.MaxCriticalEvents } + }; + } +} diff --git a/Core/Validation/Rules/StudentAssignmentRules/TooManyRegionalEventsAssignmentRule.cs b/Core/Validation/Rules/StudentAssignmentRules/TooManyRegionalEventsAssignmentRule.cs new file mode 100644 index 0000000..2ac1818 --- /dev/null +++ b/Core/Validation/Rules/StudentAssignmentRules/TooManyRegionalEventsAssignmentRule.cs @@ -0,0 +1,36 @@ +using Core.Entities; +using Core.Validation.Rules.BaseRules; + +namespace Core.Validation.Rules.StudentAssignmentRules; + +/// +/// Validation rule that checks if a student has too many regional events in their assignments +/// +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 BuildAdditionalMetadata(StudentEventStatistics statistics, ValidationConfiguration config) + { + return new Dictionary + { + { "RegionalEventCount", GetCount(statistics) }, + { "MaxRegionalEvents", config.MaxRegionalEvents } + }; + } +} diff --git a/Core/Validation/Rules/StudentRankingRules/NoIndividualEventRule.cs b/Core/Validation/Rules/StudentRankingRules/NoIndividualEventRule.cs new file mode 100644 index 0000000..76ba995 --- /dev/null +++ b/Core/Validation/Rules/StudentRankingRules/NoIndividualEventRule.cs @@ -0,0 +1,22 @@ +using Core.Entities; +using Core.Validation.Rules.BaseRules; + +namespace Core.Validation.Rules.StudentRankingRules; + +/// +/// Validation rule that checks if a student has ranked at least one individual event +/// +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"; +} diff --git a/Core/Validation/Rules/StudentRankingRules/NoOnSiteActivityRule.cs b/Core/Validation/Rules/StudentRankingRules/NoOnSiteActivityRule.cs new file mode 100644 index 0000000..a74d9ef --- /dev/null +++ b/Core/Validation/Rules/StudentRankingRules/NoOnSiteActivityRule.cs @@ -0,0 +1,22 @@ +using Core.Entities; +using Core.Validation.Rules.BaseRules; + +namespace Core.Validation.Rules.StudentRankingRules; + +/// +/// Validation rule that checks if a student has ranked at least one on-site activity +/// +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"; +} diff --git a/Core/Validation/Rules/StudentRankingRules/NoRegionalEventRule.cs b/Core/Validation/Rules/StudentRankingRules/NoRegionalEventRule.cs new file mode 100644 index 0000000..bba3622 --- /dev/null +++ b/Core/Validation/Rules/StudentRankingRules/NoRegionalEventRule.cs @@ -0,0 +1,22 @@ +using Core.Entities; +using Core.Validation.Rules.BaseRules; + +namespace Core.Validation.Rules.StudentRankingRules; + +/// +/// Validation rule that checks if a student has ranked at least one regional event +/// +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"; +} diff --git a/Core/Validation/Rules/StudentRankingRules/TooManyRegionalEventsRule.cs b/Core/Validation/Rules/StudentRankingRules/TooManyRegionalEventsRule.cs new file mode 100644 index 0000000..cf7a7e0 --- /dev/null +++ b/Core/Validation/Rules/StudentRankingRules/TooManyRegionalEventsRule.cs @@ -0,0 +1,36 @@ +using Core.Entities; +using Core.Validation.Rules.BaseRules; + +namespace Core.Validation.Rules.StudentRankingRules; + +/// +/// Validation rule that checks if a student has too many regional events in their rankings +/// +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 BuildAdditionalMetadata(Student student, ValidationConfiguration config) + { + return new Dictionary + { + { "RegionalEventCount", GetCount(student) }, + { "MaxRegionalEvents", config.MaxRegionalEvents } + }; + } +} diff --git a/Core/Validation/Rules/TeamRules/MissingCaptainRule.cs b/Core/Validation/Rules/TeamRules/MissingCaptainRule.cs new file mode 100644 index 0000000..ffef9e7 --- /dev/null +++ b/Core/Validation/Rules/TeamRules/MissingCaptainRule.cs @@ -0,0 +1,40 @@ +using Core.Entities; + +namespace Core.Validation.Rules.TeamRules; + +/// +/// Validation rule that checks if a team-based event has an assigned captain +/// +public class MissingCaptainRule : IValidationRule +{ + 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 + { + { "TeamId", entity.Id }, + { "EventName", entity.Event.Name }, + { "TeamSize", entity.Students.Count } + } + }; + } + + public bool AppliesTo(ValidationContext context) => + context == ValidationContext.Team; +} diff --git a/Core/Validation/Rules/TeamRules/TeamSizeTooLargeRule.cs b/Core/Validation/Rules/TeamRules/TeamSizeTooLargeRule.cs new file mode 100644 index 0000000..0687d98 --- /dev/null +++ b/Core/Validation/Rules/TeamRules/TeamSizeTooLargeRule.cs @@ -0,0 +1,42 @@ +using Core.Entities; + +namespace Core.Validation.Rules.TeamRules; + +/// +/// Validation rule that checks if a team has more members than the maximum allowed +/// +public class TeamSizeTooLargeRule : IValidationRule +{ + 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 + { + { "TeamId", entity.Id }, + { "EventName", entity.Event.Name }, + { "ActualSize", actualSize }, + { "MinSize", entity.Event.MinTeamSize }, + { "MaxSize", maxSize } + } + }; + } + + public bool AppliesTo(ValidationContext context) => + context == ValidationContext.Team; +} diff --git a/Core/Validation/Rules/TeamRules/TeamSizeTooSmallRule.cs b/Core/Validation/Rules/TeamRules/TeamSizeTooSmallRule.cs new file mode 100644 index 0000000..aff5b3f --- /dev/null +++ b/Core/Validation/Rules/TeamRules/TeamSizeTooSmallRule.cs @@ -0,0 +1,42 @@ +using Core.Entities; + +namespace Core.Validation.Rules.TeamRules; + +/// +/// Validation rule that checks if a team has fewer members than the minimum required +/// +public class TeamSizeTooSmallRule : IValidationRule +{ + 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 + { + { "TeamId", entity.Id }, + { "EventName", entity.Event.Name }, + { "ActualSize", actualSize }, + { "MinSize", minSize }, + { "MaxSize", entity.Event.MaxTeamSize } + } + }; + } + + public bool AppliesTo(ValidationContext context) => + context == ValidationContext.Team; +} diff --git a/Core/Validation/ValidationConfiguration.cs b/Core/Validation/ValidationConfiguration.cs new file mode 100644 index 0000000..41d45b0 --- /dev/null +++ b/Core/Validation/ValidationConfiguration.cs @@ -0,0 +1,192 @@ +using Core.Entities; +using System.Text.Json; + +namespace Core.Validation; + +/// +/// Configuration for validation thresholds and rules +/// +public class ValidationConfiguration +{ + // Event count thresholds + /// + /// Minimum recommended number of events per student (Warning if below) + /// + public int MinRecommendedEvents { get; set; } = 2; + + /// + /// Maximum recommended number of events per student (Warning if above) + /// + public int MaxRecommendedEvents { get; set; } = 4; + + /// + /// Minimum critical number of events per student (Error if below) + /// + public int MinCriticalEvents { get; set; } = 1; + + /// + /// Maximum critical number of events per student (Error if above) + /// + public int MaxCriticalEvents { get; set; } = 6; + + /// + /// Maximum recommended number of regional events per student (Warning if above) + /// + public int MaxRegionalEvents { get; set; } = 3; + + // Required event types + /// + /// Whether to require students to have at least one regional event + /// + public bool RequireRegionalEvent { get; set; } = true; + + /// + /// Whether to require students to have at least one on-site activity + /// + public bool RequireOnSiteActivity { get; set; } = true; + + /// + /// Whether to require students to have at least one individual event + /// + public bool RequireIndividualEvent { get; set; } = false; + + /// + /// Whether to require team-based events to have an assigned captain + /// + public bool RequireTeamCaptain { get; set; } = true; + + // Severity levels for each rule type + /// + /// Severity level for "No Regional Event" warnings + /// + public ValidationSeverity NoRegionalEventSeverity { get; set; } = ValidationSeverity.Warning; + + /// + /// Severity level for "No On-Site Activity" warnings + /// + public ValidationSeverity NoOnSiteActivitySeverity { get; set; } = ValidationSeverity.Warning; + + /// + /// Severity level for "No Individual Event" warnings + /// + public ValidationSeverity NoIndividualEventSeverity { get; set; } = ValidationSeverity.Warning; + + /// + /// Severity level for team size warnings + /// + public ValidationSeverity TeamSizeSeverity { get; set; } = ValidationSeverity.Warning; + + /// + /// Severity level for event count warnings + /// + public ValidationSeverity EventCountSeverity { get; set; } = ValidationSeverity.Warning; + + /// + /// Severity level for missing captain warnings + /// + public ValidationSeverity MissingCaptainSeverity { get; set; } = ValidationSeverity.Warning; + + /// + /// Severity level for too many regional events warnings + /// + public ValidationSeverity TooManyRegionalEventsSeverity { get; set; } = ValidationSeverity.Warning; + + /// + /// Default configuration matching current app behavior + /// + public static ValidationConfiguration Default => new() + { + RequireRegionalEvent = true, + RequireOnSiteActivity = true, + RequireIndividualEvent = false, + MinRecommendedEvents = 2, + MaxRecommendedEvents = 4, + MinCriticalEvents = 1, + MaxCriticalEvents = 6, + MaxRegionalEvents = 3, + RequireTeamCaptain = true, + NoRegionalEventSeverity = ValidationSeverity.Warning, + NoOnSiteActivitySeverity = ValidationSeverity.Warning, + NoIndividualEventSeverity = ValidationSeverity.Warning, + TeamSizeSeverity = ValidationSeverity.Warning, + EventCountSeverity = ValidationSeverity.Warning, + MissingCaptainSeverity = ValidationSeverity.Warning, + TooManyRegionalEventsSeverity = ValidationSeverity.Warning + }; + + /// + /// Create validation configuration from assignment parameters + /// + /// Assignment parameters to convert + /// Validation configuration matching the assignment parameters + public static ValidationConfiguration FromAssignmentParameters(AssignmentParameters parameters) + { + return new ValidationConfiguration + { + RequireRegionalEvent = parameters.RequireRegional, + RequireOnSiteActivity = parameters.RequireOnSite, + MinRecommendedEvents = parameters.EventsLowerBound, + MaxRecommendedEvents = parameters.EventsUpperBound, + MinCriticalEvents = 1, + MaxCriticalEvents = 6, + RequireTeamCaptain = true, + NoRegionalEventSeverity = ValidationSeverity.Warning, + NoOnSiteActivitySeverity = ValidationSeverity.Warning, + NoIndividualEventSeverity = ValidationSeverity.Warning, + TeamSizeSeverity = ValidationSeverity.Warning, + EventCountSeverity = ValidationSeverity.Warning, + MissingCaptainSeverity = ValidationSeverity.Warning + }; + } + + /// + /// Deserialize validation configuration from JSON string + /// + /// JSON string containing configuration + /// ValidationConfiguration instance or Default if deserialization fails + public static ValidationConfiguration FromJson(string json) + { + try + { + return JsonSerializer.Deserialize(json) ?? Default; + } + catch + { + return Default; + } + } + + /// + /// Load validation configuration from a JSON file + /// + /// Path to the JSON configuration file + /// ValidationConfiguration instance or Default if file doesn't exist or loading fails + public static async Task LoadFromFileAsync(string path) + { + try + { + if (!File.Exists(path)) + return Default; + + var json = await File.ReadAllTextAsync(path); + return FromJson(json); + } + catch + { + return Default; + } + } + + /// + /// Save this validation configuration to a JSON file + /// + /// Path where the JSON file should be saved + public async Task SaveToFileAsync(string path) + { + var json = JsonSerializer.Serialize(this, new JsonSerializerOptions + { + WriteIndented = true + }); + await File.WriteAllTextAsync(path, json); + } +} diff --git a/Core/Validation/ValidationContext.cs b/Core/Validation/ValidationContext.cs new file mode 100644 index 0000000..ec0a8d8 --- /dev/null +++ b/Core/Validation/ValidationContext.cs @@ -0,0 +1,27 @@ +namespace Core.Validation; + +/// +/// Context in which validation is being performed +/// +public enum ValidationContext +{ + /// + /// Validating a single student's event rankings + /// + StudentRanking = 0, + + /// + /// Validating a single team configuration + /// + Team = 1, + + /// + /// Validating a student's assigned events + /// + StudentAssignment = 2, + + /// + /// Validating student registration data + /// + StudentRegistration = 3 +} diff --git a/Core/Validation/ValidationService.cs b/Core/Validation/ValidationService.cs new file mode 100644 index 0000000..7f28c08 --- /dev/null +++ b/Core/Validation/ValidationService.cs @@ -0,0 +1,130 @@ +using Core.Entities; +using System.Reflection; + +namespace Core.Validation; + +/// +/// Main service for executing validation rules and generating warnings +/// +public class ValidationService +{ + private readonly ValidationConfiguration _config; + private readonly List> _studentRules; + private readonly List> _teamRules; + private readonly List> _statisticsRules; + + // Lazy static singletons for rule definitions (instantiated once per application lifetime) + private static readonly Lazy>> _studentRuleDefinitions = + new(() => DiscoverRules()); + private static readonly Lazy>> _teamRuleDefinitions = + new(() => DiscoverRules()); + private static readonly Lazy>> _statisticsRuleDefinitions = + new(() => DiscoverRules()); + + /// + /// Create a new validation service with the specified configuration + /// + /// Validation configuration (uses Default if null) + public ValidationService(ValidationConfiguration? config = null) + { + _config = config ?? ValidationConfiguration.Default; + + // Use the shared rule definitions (singleton pattern) + _studentRules = _studentRuleDefinitions.Value; + _teamRules = _teamRuleDefinitions.Value; + _statisticsRules = _statisticsRuleDefinitions.Value; + } + + /// + /// Discover all validation rules for a given entity type using reflection + /// + /// The entity type to find rules for + /// List of discovered validation rules + private static List> DiscoverRules() + { + var ruleType = typeof(IValidationRule); + + return Assembly.GetExecutingAssembly() + .GetTypes() + .Where(t => t.IsClass && !t.IsAbstract && ruleType.IsAssignableFrom(t)) + .Select(t => (IValidationRule)Activator.CreateInstance(t)!) + .ToList(); + } + + /// + /// Validate a student's event rankings + /// + /// Student to validate + /// Validation context + /// List of validation warnings + public List ValidateStudentRankings(Student student, ValidationContext context) + { + return _studentRules + .Where(rule => rule.AppliesTo(context)) + .Select(rule => rule.Validate(student, _config)) + .Where(warning => warning != null) + .Cast() + .ToList(); + } + + /// + /// Validate a team configuration + /// + /// Team to validate + /// Validation context (defaults to Team) + /// List of validation warnings + public List ValidateTeam(Team team, ValidationContext context = ValidationContext.Team) + { + return _teamRules + .Where(rule => rule.AppliesTo(context)) + .Select(rule => rule.Validate(team, _config)) + .Where(warning => warning != null) + .Cast() + .ToList(); + } + + /// + /// Validate student event assignment statistics + /// + /// Student statistics to validate + /// Validation context + /// List of validation warnings + public List ValidateStudentStatistics(StudentEventStatistics stats, ValidationContext context) + { + return _statisticsRules + .Where(rule => rule.AppliesTo(context)) + .Select(rule => rule.Validate(stats, _config)) + .Where(warning => warning != null) + .Cast() + .ToList(); + } + + /// + /// Validate all students in a collection + /// + /// Students to validate + /// Validation context + /// Dictionary mapping each student to their warnings + public Dictionary> ValidateStudents( + IEnumerable students, + ValidationContext context) + { + return students.ToDictionary( + student => student, + student => ValidateStudentRankings(student, context) + ); + } + + /// + /// Validate all teams in a collection + /// + /// Teams to validate + /// Dictionary mapping each team to their warnings + public Dictionary> ValidateTeams(IEnumerable teams) + { + return teams.ToDictionary( + team => team, + team => ValidateTeam(team) + ); + } +} diff --git a/Core/Validation/ValidationSeverity.cs b/Core/Validation/ValidationSeverity.cs new file mode 100644 index 0000000..aedf41a --- /dev/null +++ b/Core/Validation/ValidationSeverity.cs @@ -0,0 +1,17 @@ +namespace Core.Validation; + +/// +/// Severity level for validation warnings +/// +public enum ValidationSeverity +{ + /// + /// Warning that should be addressed but doesn't prevent operation + /// + Warning = 0, + + /// + /// Error that indicates a critical issue + /// + Error = 1 +} diff --git a/Core/Validation/ValidationWarning.cs b/Core/Validation/ValidationWarning.cs new file mode 100644 index 0000000..8717a22 --- /dev/null +++ b/Core/Validation/ValidationWarning.cs @@ -0,0 +1,37 @@ +namespace Core.Validation; + +/// +/// Represents a validation warning or error for a student, team, or assignment +/// +public class ValidationWarning +{ + /// + /// Human-readable warning message + /// + public required string Message { get; init; } + + /// + /// Unique code identifying the validation rule (e.g., "NO_REGIONAL_EVENT") + /// + public required string Code { get; init; } + + /// + /// Severity level of the warning + /// + public required ValidationSeverity Severity { get; init; } + + /// + /// Context in which this warning applies + /// + public required ValidationContext Context { get; init; } + + /// + /// Icon identifier for UI display (maps to AppIcons constants in WebApp) + /// + public string? IconIdentifier { get; init; } + + /// + /// Additional contextual information about the warning + /// + public Dictionary Metadata { get; init; } = new(); +} diff --git a/WebApp/Components/Features/Events/Components/EventAttributes.razor b/WebApp/Components/Features/Events/Components/EventAttributes.razor index d5e0212..710013b 100644 --- a/WebApp/Components/Features/Events/Components/EventAttributes.razor +++ b/WebApp/Components/Features/Events/Components/EventAttributes.razor @@ -1,6 +1,12 @@ @using WebApp.Models + +@if (EventDefinition.LevelOfEffort.HasValue) +{ + @EventDefinition.LevelOfEffort +} + @foreach (var charStr in _attributes.Select(c => c.ToString())) { if (AppIcons.IconTooltips.TryGetValue(charStr, out var tooltip)) @@ -23,9 +29,7 @@ protected override void OnParametersSet() { - _attributes = AppIcons.LevelOfEffortIcon(EventDefinition.LevelOfEffort); - _attributes += " "; - _attributes += EventDefinition.EventFormat == EventFormat.Individual ? AppIcons.IndividualEvent : " "; + _attributes = EventDefinition.EventFormat == EventFormat.Individual ? AppIcons.IndividualEvent : " "; _attributes += EventDefinition.OnSiteActivity ? AppIcons.OnSiteActivity : " "; _attributes += EventDefinition.RegionalEvent ? AppIcons.RegionalEvent : " "; _attributes += EventDefinition.InterviewOrPresentation ? AppIcons.PresentationEvent : " "; diff --git a/WebApp/Components/Features/Students/EventRanking.razor b/WebApp/Components/Features/Students/EventRanking.razor index 2f1f57e..b5c698b 100644 --- a/WebApp/Components/Features/Students/EventRanking.razor +++ b/WebApp/Components/Features/Students/EventRanking.razor @@ -2,7 +2,9 @@ @attribute [Authorize] @using Microsoft.EntityFrameworkCore @using WebApp.Models +@using Core.Validation @inject AppDbContext Context +@inject ValidationService ValidationService @rendermode InteractiveServer Student Event Ranks - TSA Chapter Organizer @@ -37,25 +39,7 @@ else @context.TsaYear Edit - @if (!context.RankedEvents.Any(re => re.OnSiteActivity)) - { - - @AppIcons.OnSiteActivity - - } - @if (!context.RankedEvents.Any(re => re.RegionalEvent)) - { - - @AppIcons.RegionalEvent - - } - - @if (context.RankedEvents.All(re => re.EventFormat != EventFormat.Individual)) - { - - @AppIcons.IndividualEvent - - } + @for (var i = 1; i <= 10; i++) { @@ -154,4 +138,9 @@ else _maxEventStudentRankings = _eventStudentRankings.Max(esr => esr.StudentRanking.Length); } + + private List GetStudentWarnings(Student student) + { + return ValidationService.ValidateStudentRankings(student, ValidationContext.StudentRanking); + } } diff --git a/WebApp/Components/Features/Students/Registration.razor b/WebApp/Components/Features/Students/Registration.razor index c78f576..5bd5bea 100644 --- a/WebApp/Components/Features/Students/Registration.razor +++ b/WebApp/Components/Features/Students/Registration.razor @@ -2,8 +2,10 @@ @attribute [Authorize] @using Microsoft.EntityFrameworkCore @using WebApp.Models +@using Core.Validation @inject AppDbContext Context @inject WebApp.LocalStorageService LocalStorage +@inject ValidationService ValidationService Registration - TSA Chapter Organizer @@ -43,6 +45,11 @@ + + + + + @if (_showGrade) { @@ -239,6 +246,20 @@ : data.OrderBy(sortExpression); } + private List GetRegistrationWarnings(Student student) + { + // Create StudentEventStatistics from the student's teams + var stats = new StudentEventStatistics + { + Student = student, + Events = student.Teams?.Where(t => t?.Event != null) + .Select(t => t.Event) + .ToList() ?? new List() + }; + + return ValidationService.ValidateStudentStatistics(stats, ValidationContext.StudentRegistration); + } + public class StudentTeamInfo { public required Student Student { get; init; } diff --git a/WebApp/Components/Features/Teams/Assignment.razor b/WebApp/Components/Features/Teams/Assignment.razor index b95e966..182d0af 100644 --- a/WebApp/Components/Features/Teams/Assignment.razor +++ b/WebApp/Components/Features/Teams/Assignment.razor @@ -1,12 +1,14 @@ @page "/teams/assignment" @attribute [Authorize] @using Core.Calculation +@using Core.Validation @using Microsoft.EntityFrameworkCore @using WebApp.Models @using EventAssignment = Core.Calculation.EventAssignment @inject AppDbContext Context @inject IDialogService DialogService @inject NavigationManager NavigationManager +@inject ValidationService ValidationService Event Assignment - TSA Chapter Organizer @@ -134,18 +136,8 @@ @context.Student.FirstName @context.EventCount @context.TotalLevelOfEffort - @if (!context.HasOnSiteActivity) - { - - @AppIcons.OnSiteActivity - - } - @if (!context.HasRegionalEvent) - { - - @AppIcons.RegionalEvent - - } + + @@ -313,6 +305,7 @@ public bool TestSwitch { get; set; } = false; private readonly AssignmentParameters _parameters = new() { RequireOnSite = false, RequireRegional = false }; + private ValidationService _validationService = new ValidationService(ValidationConfiguration.Default); private List? _events; private List? _students; @@ -367,6 +360,7 @@ private async Task> SolveAssignments(TableState arg1, CancellationToken arg2) { _isSolving = true; + UpdateValidationConfig(); var eventAssignment = new EventAssignment(_events, _students, _parameters); foreach (var requirement in _assignmentRequirements) { @@ -396,6 +390,17 @@ return new TableData {Items = _statistics}; } + private void UpdateValidationConfig() + { + var config = ValidationConfiguration.FromAssignmentParameters(_parameters); + _validationService = new ValidationService(config); + } + + private List GetStatisticsWarnings(StudentEventStatistics stats) + { + return _validationService.ValidateStudentStatistics(stats, ValidationContext.StudentAssignment); + } + private async Task> ReloadAssignmentRequirements(TableState arg1, CancellationToken arg2) { return new TableData { Items = _assignmentRequirements }; diff --git a/WebApp/Components/Pages/ChapterSettings.razor b/WebApp/Components/Pages/ChapterSettings.razor new file mode 100644 index 0000000..ad9b3be --- /dev/null +++ b/WebApp/Components/Pages/ChapterSettings.razor @@ -0,0 +1,186 @@ +@page "/settings/chapter" +@attribute [Authorize(Roles = AuthRoles.Administrator)] +@using WebApp.Authentication +@using WebApp.Models +@using System.Text.Json +@inject IWebHostEnvironment Environment +@inject IConfiguration Configuration + +@rendermode InteractiveServer + +Chapter Settings + + + Chapter Settings + Configure chapter information. Changes take effect on next application restart. + + @if (_settings != null) + { + + Basic Information + + + + + + + + + + + + + + + Chapter IDs + + + + + + + + + + + + + + + + + + @if (_isSaving) + { + + Saving... + } + else + { + Save Settings + } + + + + + + @if (!string.IsNullOrEmpty(_statusMessage)) + { + @_statusMessage + } + } + else + { + + } + + +@code { + private Models.ChapterSettings? _settings; + private bool _isSaving; + private string? _statusMessage; + private Severity _statusSeverity = Severity.Success; + + protected override void OnInitialized() + { + // Load from IConfiguration + _settings = Configuration.GetSection("ChapterSettings").Get() + ?? new Models.ChapterSettings(); + } + + private string GetAppSettingsPath() + { + return Path.Combine( + Environment.ContentRootPath, + "Data", + "appsettings.json"); + } + + private async Task SaveSettings() + { + if (_settings == null) return; + + _isSaving = true; + _statusMessage = null; + + try + { + var appSettingsPath = GetAppSettingsPath(); + + // Ensure Data directory exists + var dataDir = Path.GetDirectoryName(appSettingsPath); + if (dataDir != null && !Directory.Exists(dataDir)) + { + Directory.CreateDirectory(dataDir); + } + + // Read existing appsettings or create new + JsonDocument? existingDoc = null; + Dictionary settings; + + if (File.Exists(appSettingsPath)) + { + var existingJson = await File.ReadAllTextAsync(appSettingsPath); + existingDoc = JsonDocument.Parse(existingJson); + settings = JsonSerializer.Deserialize>(existingJson) + ?? new Dictionary(); + } + else + { + settings = new Dictionary(); + } + + // Update ChapterSettings section + settings["ChapterSettings"] = _settings; + + // Write back to file + var options = new JsonSerializerOptions { WriteIndented = true }; + var json = JsonSerializer.Serialize(settings, options); + await File.WriteAllTextAsync(appSettingsPath, json); + + existingDoc?.Dispose(); + + _statusMessage = "Settings saved successfully! Changes will take effect on next application restart."; + _statusSeverity = Severity.Success; + } + catch (Exception ex) + { + _statusMessage = $"Error saving settings: {ex.Message}"; + _statusSeverity = Severity.Error; + } + finally + { + _isSaving = false; + } + } +} diff --git a/WebApp/Components/Pages/Home.razor b/WebApp/Components/Pages/Home.razor index 5798448..a92424a 100644 --- a/WebApp/Components/Pages/Home.razor +++ b/WebApp/Components/Pages/Home.razor @@ -26,6 +26,9 @@ + +Data + - - - + + Tools + + + + + + + @code { private int _eventCount; diff --git a/WebApp/Components/Pages/ValidationSettings.razor b/WebApp/Components/Pages/ValidationSettings.razor new file mode 100644 index 0000000..8308252 --- /dev/null +++ b/WebApp/Components/Pages/ValidationSettings.razor @@ -0,0 +1,284 @@ +@page "/settings/validation" +@attribute [Authorize(Roles = AuthRoles.Administrator)] +@using Core.Validation +@using WebApp.Authentication +@using System.Text.Json +@inject IWebHostEnvironment Environment +@inject IConfiguration Configuration + +@rendermode InteractiveServer + +Validation Settings + + + Validation Settings + Configure validation rules and thresholds. Changes take effect on next application restart. + + @if (_config != null) + { + + Event Count Thresholds + + + + + + + + + + + + + + + + + + + + + Required Event Types + + + + + Students must have at least one regional event + + + + + + Students must have at least one on-site activity + + + + + + Students must have at least one individual event + + + + + + Team events must have an assigned captain + + + + + + + Validation Severity Levels + + + + Warning + Error + + + + + Warning + Error + + + + + Warning + Error + + + + + Warning + Error + + + + + Warning + Error + + + + + Warning + Error + + + + + Warning + Error + + + + + + + + + + @if (_isSaving) + { + + Saving... + } + else + { + Save Configuration + } + + + Reset to Defaults + + + + + + @if (!string.IsNullOrEmpty(_statusMessage)) + { + @_statusMessage + } + } + else + { + + } + + +@code { + private ValidationConfiguration? _config; + private bool _isSaving; + private string? _statusMessage; + private Severity _statusSeverity = Severity.Success; + + protected override void OnInitialized() + { + // Load from IConfiguration + _config = Configuration.GetSection("ValidationSettings").Get() + ?? ValidationConfiguration.Default; + } + + private string GetAppSettingsPath() + { + return Path.Combine( + Environment.ContentRootPath, + "Data", + "appsettings.json"); + } + + private async Task SaveConfiguration() + { + if (_config == null) return; + + _isSaving = true; + _statusMessage = null; + + try + { + var appSettingsPath = GetAppSettingsPath(); + + // Ensure Data directory exists + var dataDir = Path.GetDirectoryName(appSettingsPath); + if (dataDir != null && !Directory.Exists(dataDir)) + { + Directory.CreateDirectory(dataDir); + } + + // Read existing appsettings or create new + JsonDocument? existingDoc = null; + Dictionary settings; + + if (File.Exists(appSettingsPath)) + { + var existingJson = await File.ReadAllTextAsync(appSettingsPath); + existingDoc = JsonDocument.Parse(existingJson); + settings = JsonSerializer.Deserialize>(existingJson) + ?? new Dictionary(); + } + else + { + settings = new Dictionary(); + } + + // Update ValidationSettings section + settings["ValidationSettings"] = _config; + + // Write back to file + var options = new JsonSerializerOptions { WriteIndented = true }; + var json = JsonSerializer.Serialize(settings, options); + await File.WriteAllTextAsync(appSettingsPath, json); + + existingDoc?.Dispose(); + + _statusMessage = "Configuration saved successfully! Changes will take effect on next application restart."; + _statusSeverity = Severity.Success; + } + catch (Exception ex) + { + _statusMessage = $"Error saving configuration: {ex.Message}"; + _statusSeverity = Severity.Error; + } + finally + { + _isSaving = false; + } + } + + private void ResetToDefaults() + { + _config = ValidationConfiguration.Default; + _statusMessage = "Configuration reset to defaults. Click Save to apply."; + _statusSeverity = Severity.Info; + } +} diff --git a/WebApp/Components/Shared/Layout/NavMenu.razor b/WebApp/Components/Shared/Layout/NavMenu.razor index bf1aee1..5b4df0a 100644 --- a/WebApp/Components/Shared/Layout/NavMenu.razor +++ b/WebApp/Components/Shared/Layout/NavMenu.razor @@ -1,4 +1,5 @@ @using WebApp.Models +@using WebApp.Authentication @inject IConfiguration Configuration @@ -20,5 +21,10 @@ Event Ranking Team Assignment + + + Chapter Settings + Validation Settings + diff --git a/WebApp/Components/Shared/ValidationWarnings.razor b/WebApp/Components/Shared/ValidationWarnings.razor new file mode 100644 index 0000000..e0c3a90 --- /dev/null +++ b/WebApp/Components/Shared/ValidationWarnings.razor @@ -0,0 +1,37 @@ +@using Core.Validation +@using WebApp.Models + +@if (Warnings != null && Warnings.Any()) +{ + + @foreach (var warning in Warnings) + { + var color = warning.Severity == ValidationSeverity.Error ? Color.Error : Color.Warning; + var icon = GetIconFromIdentifier(warning.IconIdentifier); + + + @icon + + } + +} + +@code { + [Parameter] + public List? Warnings { get; set; } + + private string GetIconFromIdentifier(string? identifier) + { + if (string.IsNullOrEmpty(identifier)) + return ""; + + return identifier switch + { + "RegionalEvent" => AppIcons.RegionalEvent, + "OnSiteActivity" => AppIcons.OnSiteActivity, + "IndividualEvent" => AppIcons.IndividualEvent, + "Captain" => AppIcons.Captain, + _ => "" + }; + } +} diff --git a/WebApp/Models/AppIcons.cs b/WebApp/Models/AppIcons.cs index 61c7b74..9cad260 100644 --- a/WebApp/Models/AppIcons.cs +++ b/WebApp/Models/AppIcons.cs @@ -17,9 +17,9 @@ namespace WebApp.Models return loe switch { - 1 => "‧", - 2 => "⁚", - 3 => "⁝", + 1 => "1", + 2 => "2", + 3 => "3", _ => Icons.Material.Filled.QuestionMark }; } diff --git a/WebApp/Models/ChapterSettings.cs b/WebApp/Models/ChapterSettings.cs new file mode 100644 index 0000000..5d8550c --- /dev/null +++ b/WebApp/Models/ChapterSettings.cs @@ -0,0 +1,37 @@ +namespace WebApp.Models; + +/// +/// Configuration for chapter-specific information +/// +public class ChapterSettings +{ + /// + /// Full chapter name (e.g., "Your Chapter Name") + /// + public string Name { get; set; } = "Your Chapter Name"; + + /// + /// Short chapter name abbreviation (e.g., "YCN") + /// + public string ShortName { get; set; } = "YCN"; + + /// + /// National chapter ID (4 digits, e.g., "0000") + /// + public string NationalId { get; set; } = "0000"; + + /// + /// State chapter ID (5 digits, e.g., "00000") + /// + public string StateId { get; set; } = "00000"; + + /// + /// Regional chapter ID (5 digits, e.g., "00000") + /// + public string RegionalId { get; set; } = "00000"; + + /// + /// Competition year (e.g., "2026") + /// + public string CompetitionYear { get; set; } = "2026"; +} diff --git a/WebApp/Program.cs b/WebApp/Program.cs index babefdb..941ad4d 100644 --- a/WebApp/Program.cs +++ b/WebApp/Program.cs @@ -11,62 +11,66 @@ using WebApp.Logging; var builder = WebApplication.CreateBuilder(args); -// Load optional appsettings from Data directory in production (overrides defaults) -// In development, use appsettings.Development.json instead -if (builder.Environment.IsProduction()) +// Load appsettings from Data directory (works in both development and production) +// This allows runtime configuration changes without touching the codebase +var dataAppSettingsPath = Path.Combine(builder.Environment.ContentRootPath, "Data", "appsettings.json"); +if (!File.Exists(dataAppSettingsPath)) { - var dataAppSettingsPath = Path.Combine(builder.Environment.ContentRootPath, "Data", "appsettings.json"); - if (!File.Exists(dataAppSettingsPath)) + Console.WriteLine($"appsettings.json not found at {dataAppSettingsPath}. Creating from template..."); + + try { - Console.WriteLine($"appsettings.json not found at {dataAppSettingsPath}. Creating from template..."); + // Ensure Data directory exists + Directory.CreateDirectory(Path.Combine(builder.Environment.ContentRootPath, "Data")); - try + // Copy ChapterSettings and ValidationSettings from the base appsettings.json + var baseAppSettingsPath = Path.Combine(builder.Environment.ContentRootPath, "appsettings.json"); + if (File.Exists(baseAppSettingsPath)) { - // Ensure Data directory exists - Directory.CreateDirectory(Path.Combine(builder.Environment.ContentRootPath, "Data")); + var baseConfig = File.ReadAllText(baseAppSettingsPath); + var baseDoc = JsonDocument.Parse(baseConfig); - // Copy ChapterSettings from the base appsettings.json - var baseAppSettingsPath = Path.Combine(builder.Environment.ContentRootPath, "appsettings.json"); - if (File.Exists(baseAppSettingsPath)) + var templateSettings = new Dictionary(); + + if (baseDoc.RootElement.TryGetProperty("ChapterSettings", out var chapterSettings)) { - var baseConfig = File.ReadAllText(baseAppSettingsPath); - var baseDoc = JsonDocument.Parse(baseConfig); + templateSettings["ChapterSettings"] = JsonSerializer.Deserialize(chapterSettings.GetRawText()); + } - if (baseDoc.RootElement.TryGetProperty("ChapterSettings", out var chapterSettings)) + if (baseDoc.RootElement.TryGetProperty("ValidationSettings", out var validationSettings)) + { + templateSettings["ValidationSettings"] = JsonSerializer.Deserialize(validationSettings.GetRawText()); + } + + if (templateSettings.Any()) + { + var json = JsonSerializer.Serialize(templateSettings, new JsonSerializerOptions { - var templateSettings = new - { - ChapterSettings = JsonSerializer.Deserialize(chapterSettings.GetRawText()) - }; + WriteIndented = true + }); - var json = JsonSerializer.Serialize(templateSettings, new JsonSerializerOptions - { - WriteIndented = true - }); - - File.WriteAllText(dataAppSettingsPath, json); - Console.WriteLine("appsettings.json created in Data directory. Please update ChapterSettings with your chapter information."); - } - else - { - Console.WriteLine("WARNING: ChapterSettings not found in base appsettings.json"); - } + File.WriteAllText(dataAppSettingsPath, json); + Console.WriteLine("appsettings.json created in Data directory."); + } + else + { + Console.WriteLine("WARNING: No settings to copy from base appsettings.json"); } } - catch (Exception ex) - { - Console.WriteLine($"WARNING: Unable to create appsettings.json: {ex.Message}"); - } } - - // Add Data/appsettings.json as additional configuration source - if (File.Exists(dataAppSettingsPath)) + catch (Exception ex) { - builder.Configuration.AddJsonFile(dataAppSettingsPath, optional: true, reloadOnChange: true); - Console.WriteLine($"Loaded configuration from {dataAppSettingsPath}"); + Console.WriteLine($"WARNING: Unable to create appsettings.json: {ex.Message}"); } } +// Add Data/appsettings.json as additional configuration source (all environments) +if (File.Exists(dataAppSettingsPath)) +{ + builder.Configuration.AddJsonFile(dataAppSettingsPath, optional: true, reloadOnChange: true); + Console.WriteLine($"Loaded configuration from {dataAppSettingsPath}"); +} + // Configure Serilog with custom handling for antiforgery errors builder.Host.UseSerilog((context, configuration) => { @@ -172,6 +176,15 @@ builder.Services.AddScoped(); // State container for maintaining state per user connection (Blazor Server) builder.Services.AddScoped(); +// Load validation configuration from appsettings (or use default if not found) +var validationConfig = builder.Configuration + .GetSection("ValidationSettings") + .Get() ?? Core.Validation.ValidationConfiguration.Default; + +// Validation service with loaded configuration +builder.Services.AddScoped(sp => + new Core.Validation.ValidationService(validationConfig)); + // Add authentication services builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); diff --git a/WebApp/appsettings.json b/WebApp/appsettings.json index 115a543..91d81b9 100644 --- a/WebApp/appsettings.json +++ b/WebApp/appsettings.json @@ -17,5 +17,23 @@ "StateId": "00000", "RegionalId": "00000", "CompetitionYear": "2026" + }, + "ValidationSettings": { + "MinRecommendedEvents": 2, + "MaxRecommendedEvents": 4, + "MinCriticalEvents": 1, + "MaxCriticalEvents": 6, + "MaxRegionalEvents": 3, + "RequireRegionalEvent": true, + "RequireOnSiteActivity": true, + "RequireIndividualEvent": false, + "RequireTeamCaptain": true, + "NoRegionalEventSeverity": "Warning", + "NoOnSiteActivitySeverity": "Warning", + "NoIndividualEventSeverity": "Warning", + "TeamSizeSeverity": "Warning", + "EventCountSeverity": "Warning", + "MissingCaptainSeverity": "Warning", + "TooManyRegionalEventsSeverity": "Warning" } } diff --git a/WebApp/wwwroot/app.css b/WebApp/wwwroot/app.css index bc91084..49ff972 100644 --- a/WebApp/wwwroot/app.css +++ b/WebApp/wwwroot/app.css @@ -93,3 +93,15 @@ h1:focus { .event-rank-4 { background-color: #ffe599; } .event-rank-5 { background-color: #fff2cc; } .event-rank-6 { background-color: #fffaea; } + +.numberCircle { + display: flex; + width: fit-content; + /* min-width: .2rem; */ + padding: 0; + align-items: center; + justify-content: center; + aspect-ratio: 10/1; + border-radius: 50%; + border: 1px solid; +} \ No newline at end of file