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