Skip to content

Commit e509a00

Browse files
Validation messages enhanced (#511)
* wip * Tech debt: validation with better messages * current iteration number * linting fixes * lint fixes * sonar fixes * sonar fixes * Update app.py sonar ignore
1 parent 71b998e commit e509a00

14 files changed

Lines changed: 174 additions & 24 deletions

File tree

index.html

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<!DOCTYPE html>
22
<html lang="en">
33
<head>
4-
<meta charset="UTF-8"/>
4+
<meta charset="UTF-8" />
55
<title>Campaign Config Validator - NHS Digital</title>
66
<script src="https://cdn.jsdelivr.net/pyodide/v0.26.4/full/pyodide.js"></script>
77
<style>
@@ -216,18 +216,22 @@ <h3>Visualiser Output</h3>
216216
const jsonInput = document.getElementById("jsonfile");
217217
const runBtn = document.getElementById("run");
218218

219-
function log(text) {
220-
let cleanText = text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
221-
// Handle ANSI Colors for Pydantic output
222-
cleanText = cleanText
223-
.replace(/\x1b\[92m/g, '<span class="ansi-green">')
224-
.replace(/\x1b\[93m/g, '<span class="ansi-yellow">')
225-
.replace(/\x1b\[91m/g, '<span class="ansi-red">')
226-
.replace(/\x1b\[0m/g, '</span>');
227-
228-
output.innerHTML += cleanText + "\n";
229-
output.scrollTop = output.scrollHeight;
219+
function log(text) {
220+
let cleanText = text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
221+
// ANSI color replacements
222+
cleanText = cleanText
223+
.replace(/\x1b\[92m/g, '<span class="ansi-grey">') // validator/method green
224+
.replace(/\x1b\[93m/g, '<span class="ansi-yellow">') // general yellow
225+
.replace(/\x1b\[34m/g, '<span style="color:#005eb8;font-weight:bold">') // blue
226+
.replace(/\x1b\[33m/g, '<span class="ansi-yellow">') // colon yellow
227+
.replace(/\x1b\[0m/g, '</span>'); // reset
228+
229+
if (cleanText.includes("Valid Config")) {
230+
cleanText = cleanText.replace(/Valid Config/g, '<span style="font-size:2em;font-weight:bold;color:#007f3b">Valid Config</span>');
230231
}
232+
output.innerHTML += cleanText + "\n";
233+
output.scrollTop = output.scrollHeight;
234+
}
231235

232236
function clearLog() {
233237
output.innerHTML = "";
@@ -248,6 +252,8 @@ <h3>Visualiser Output</h3>
248252
"src/eligibility_signposting_api/model/campaign_config.py",
249253
"src/eligibility_signposting_api/config/__init__.py",
250254
"src/eligibility_signposting_api/config/constants.py",
255+
"src/rules_validation_api/decorators/__init__.py",
256+
"src/rules_validation_api/decorators/tracker.py",
251257
"src/rules_validation_api/__init__.py",
252258
"src/rules_validation_api/validators/__init__.py",
253259
"src/rules_validation_api/validators/rules_validator.py",

src/eligibility_signposting_api/logging/logs_manager.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,12 @@ def wrapper(event: LambdaEvent, context: LambdaContext) -> dict[str, Any] | None
3030

3131

3232
class EnrichedJsonFormatter(JsonFormatter):
33-
def add_fields(self, log_data: dict[str, Any], record: logging.LogRecord, message_dict: dict[str, Any]) -> None:
33+
def add_fields(
34+
self,
35+
log_data: dict[str, Any],
36+
record: logging.LogRecord,
37+
message_dict: dict[str, Any],
38+
) -> None:
3439
log_data["request_id"] = request_id_context_var.get() or "-"
3540
super().add_fields(log_data, record, message_dict)
3641

src/eligibility_signposting_api/model/campaign_config.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,7 @@ class StatusText(BaseModel):
238238
not_actionable: str | None = Field(None, alias="NotActionable")
239239
actionable: str | None = Field(None, alias="Actionable")
240240

241-
model_config = {"populate_by_name": True}
241+
model_config = {"populate_by_name": True, "extra": "ignore"}
242242

243243

244244
class RuleEntry(BaseModel):
@@ -277,6 +277,12 @@ class Iteration(BaseModel):
277277

278278
model_config = {"populate_by_name": True, "arbitrary_types_allowed": True, "extra": "ignore"}
279279

280+
def __init__(self, **data: dict[str, typing.Any]) -> None:
281+
super().__init__(**data)
282+
# Ensure each rule knows its parent iteration
283+
for rule in self.iteration_rules:
284+
rule.set_parent(self)
285+
280286
@field_validator("iteration_date", mode="before")
281287
@classmethod
282288
def parse_dates(cls, v: str | date) -> date:
@@ -300,12 +306,6 @@ def parse_dates(cls, v: str | date) -> date:
300306
def serialize_dates(v: date, _info: SerializationInfo) -> str:
301307
return v.strftime("%Y%m%d")
302308

303-
@model_validator(mode="after")
304-
def attach_rule_parents(self) -> Iteration:
305-
for rule in self.iteration_rules:
306-
rule.set_parent(self)
307-
return self
308-
309309
def __str__(self) -> str:
310310
return json.dumps(self.model_dump(by_alias=True), indent=2)
311311

src/rules_validation_api/app.py

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22
import json
33
import logging
44
import sys
5+
from collections import defaultdict
56
from pathlib import Path
67

78
from pydantic import ValidationError
89

10+
from rules_validation_api.decorators.tracker import VALIDATORS_CALLED
911
from rules_validation_api.validators.rules_validator import RulesValidation
1012

1113
logging.basicConfig(
@@ -18,11 +20,12 @@
1820
RESET = "\033[0m"
1921
YELLOW = "\033[93m"
2022
RED = "\033[91m"
23+
BLUE = "\033[34m"
2124

2225

2326
def refine_error(e: ValidationError) -> str:
2427
"""Return a very short, single-line error message."""
25-
lines = [f"Validation Error: {len(e.errors())} validation error(s)"]
28+
lines = [f"Validation Error: {len(e.errors())} validation error(s)"]
2629

2730
for err in e.errors():
2831
loc = ".".join(str(x) for x in err["loc"])
@@ -34,21 +37,43 @@ def refine_error(e: ValidationError) -> str:
3437
return "\n".join(lines)
3538

3639

37-
def main() -> None:
40+
def main() -> None: # pragma: no cover
3841
parser = argparse.ArgumentParser(description="Validate campaign configuration.")
3942
parser.add_argument("--config_path", required=True, help="Path to the campaign config JSON file")
4043
args = parser.parse_args()
4144

4245
try:
4346
with Path(args.config_path).open() as file:
4447
json_data = json.load(file)
45-
RulesValidation(**json_data)
48+
result = RulesValidation(**json_data)
4649
sys.stdout.write(f"{GREEN}Valid Config{RESET}\n")
50+
sys.stdout.write(
51+
f"{YELLOW}Current Iteration Number is {RESET}{GREEN}"
52+
f"{result.campaign_config.current_iteration.iteration_number}{RESET}\n"
53+
)
54+
55+
# Group by class
56+
grouped = defaultdict(list)
57+
for v in VALIDATORS_CALLED:
58+
cls, method = v.split(":", 1)
59+
grouped[cls].append(method.strip())
60+
61+
# Print grouped
62+
for cls_name in sorted(grouped.keys(), reverse=True):
63+
methods = sorted(grouped[cls_name])
64+
# First method prints class name
65+
first = methods[0]
66+
colored = f"{BLUE}{cls_name}{RESET}{YELLOW}:{RESET}{GREEN}{first}{RESET}\n"
67+
sys.stdout.write(colored)
68+
# Rest methods indented
69+
for method_name in methods[1:]:
70+
colored = f"{' ' * len(cls_name)}{YELLOW}:{RESET}{GREEN}{method_name}{RESET}\n"
71+
sys.stdout.write(colored)
4772

4873
except ValidationError as e:
4974
clean = refine_error(e)
5075
sys.stderr.write(f"{YELLOW}{clean}{RESET}\n")
5176

5277

53-
if __name__ == "__main__":
78+
if __name__ == "__main__": # pragma: no cover
5479
main()

src/rules_validation_api/decorators/__init__.py

Whitespace-only changes.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from typing import Self
2+
3+
from pydantic import model_validator
4+
5+
VALIDATORS_CALLED: list[str] = []
6+
7+
8+
# --- Mixin and decorator to track validators ---
9+
class TrackValidatorsMixin:
10+
"""
11+
Mixin to track all validator names in a Pydantic model.
12+
"""
13+
14+
@model_validator(mode="after")
15+
def _track_validators(self) -> Self:
16+
for name in dir(self):
17+
if name.startswith(("validate_", "check_")) and callable(getattr(self, name)):
18+
full_name = f"{self.__class__.__name__}:{name}"
19+
if full_name not in VALIDATORS_CALLED:
20+
VALIDATORS_CALLED.append(full_name)
21+
return self
22+
23+
24+
def track_validators(cls) -> type: # noqa:ANN001
25+
"""
26+
Decorator to add the tracking mixin to a Pydantic model.
27+
"""
28+
return type(cls.__name__, (TrackValidatorsMixin, cls), {})

src/rules_validation_api/validators/actions_mapper_validator.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
from pydantic import ValidationError, model_validator
22

33
from eligibility_signposting_api.model.campaign_config import ActionsMapper
4+
from rules_validation_api.decorators.tracker import track_validators
45
from rules_validation_api.validators.available_action_validator import AvailableActionValidation
56

67

8+
@track_validators
79
class ActionsMapperValidation(ActionsMapper):
810
@model_validator(mode="after")
911
def validate_keys(self) -> "ActionsMapperValidation":

src/rules_validation_api/validators/available_action_validator.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
from pydantic import field_validator
44

55
from eligibility_signposting_api.model.campaign_config import AvailableAction
6+
from rules_validation_api.decorators.tracker import track_validators
67

78

9+
@track_validators
810
class AvailableActionValidation(AvailableAction):
911
@field_validator("action_description")
1012
@classmethod

src/rules_validation_api/validators/campaign_config_validator.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@
55
from pydantic import field_validator, model_validator
66

77
from eligibility_signposting_api.model.campaign_config import CampaignConfig, Iteration
8+
from rules_validation_api.decorators.tracker import track_validators
89
from rules_validation_api.validators.iteration_validator import IterationValidation
910

1011

12+
@track_validators
1113
class CampaignConfigValidation(CampaignConfig):
1214
@field_validator("iterations")
1315
@classmethod
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from eligibility_signposting_api.model.campaign_config import IterationCohort
2+
from rules_validation_api.decorators.tracker import track_validators
23

34

5+
@track_validators
46
class IterationCohortValidation(IterationCohort):
57
pass

0 commit comments

Comments
 (0)