Skip to content

Commit 92ab44e

Browse files
Eli 557 enhance validation rules (#482)
* ELI 557 - Iteration Date must be >= Campaign Start Date AND <= Campaign End Date * ELI 557 - Iteration id must be unique * validate approval minimum and maximum * lint fix * fix for approve minimum and maximum validator * date fix * attribute name is optional only for cohort attribte level * attribute level and attribute target relation * cohort label md * refactored * lint fix * code cleanup * cohort priority should be unique * cohort priority should be unique * rules mapper has its own class now * Iteration dates are checked for each iteration together * Iteration all iteration rules errors are reported together * refined the error messages * validate attribute target * Json schema error refined for easy readability * md file fix
1 parent be5a733 commit 92ab44e

File tree

16 files changed

+758
-144
lines changed

16 files changed

+758
-144
lines changed

index.html

Lines changed: 54 additions & 14 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>
@@ -20,16 +20,19 @@
2020
padding: 12px 16px;
2121
border-bottom: 4px solid white; /* NHS Standard header spacing */
2222
}
23+
2324
.nhs-header-content {
2425
max-width: 960px;
2526
margin: 0 auto;
2627
display: flex;
2728
align-items: center;
2829
}
30+
2931
.nhs-logo {
3032
width: 80px;
3133
height: 32px;
3234
}
35+
3336
.nhs-container {
3437
max-width: 960px;
3538
margin: 2rem auto;
@@ -38,17 +41,20 @@
3841
box-sizing: border-box;
3942
flex: 1;
4043
}
44+
4145
h1 {
4246
font-size: 2.5rem;
4347
font-weight: 700;
4448
margin-bottom: 1.5rem;
4549
}
50+
4651
.nhs-card {
4752
background: white;
4853
border: 1px solid #d8dde0;
4954
padding: 2rem;
5055
margin-bottom: 2rem;
5156
}
57+
5258
.status-bar {
5359
background: #e8edee;
5460
color: #212b32;
@@ -60,6 +66,7 @@
6066
justify-content: space-between;
6167
align-items: center;
6268
}
69+
6370
.nhs-button {
6471
background-color: #007f3b; /* NHS Green */
6572
color: white;
@@ -76,25 +83,30 @@
7683
text-decoration: none;
7784
display: inline-block;
7885
}
86+
7987
.nhs-button:hover:not(:disabled) {
8088
background-color: #00642e; /* Darker Green hover */
8189
}
90+
8291
.nhs-button:active:not(:disabled) {
8392
transform: translateY(4px); /* Button press effect */
8493
box-shadow: none;
8594
}
95+
8696
.nhs-button:disabled {
8797
background-color: #d8dde0;
8898
color: #768692;
8999
box-shadow: none;
90100
cursor: not-allowed;
91101
}
102+
92103
label {
93104
display: block;
94105
margin-bottom: 8px;
95106
font-weight: 600;
96107
font-size: 1.1rem;
97108
}
109+
98110
input[type="file"] {
99111
font-size: 1.1rem;
100112
padding: 10px;
@@ -104,6 +116,7 @@
104116
box-sizing: border-box;
105117
cursor: pointer;
106118
}
119+
107120
.log {
108121
background: #212b32;
109122
color: white;
@@ -116,9 +129,24 @@
116129
border-left: 10px solid #768692;
117130
}
118131

119-
.ansi-green { color: #007f3b; font-weight: bold; background: #ccffdd; padding: 2px 5px; }
120-
.ansi-red { color: #d81e05; font-weight: bold; background: #ffd1d1; padding: 2px 5px;}
121-
.ansi-yellow { color: #ffb81c; font-weight: bold; }
132+
.ansi-green {
133+
color: #007f3b;
134+
font-weight: bold;
135+
background: #ccffdd;
136+
padding: 2px 5px;
137+
}
138+
139+
.ansi-red {
140+
color: #d81e05;
141+
font-weight: bold;
142+
background: #ffd1d1;
143+
padding: 2px 5px;
144+
}
145+
146+
.ansi-yellow {
147+
color: #ffb81c;
148+
font-weight: bold;
149+
}
122150

123151
footer {
124152
background: #d8dde0;
@@ -135,7 +163,8 @@
135163
<div class="nhs-header-content">
136164
<svg class="nhs-logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 16" aria-hidden="true">
137165
<path fill="#fff" d="M0 0h40v16H0z"></path>
138-
<path fill="#005eb8" d="M3.9 1.5h4.4l2.6 9h.1l1.8-9h3.3l-2.8 13H9l-2.7-9h-.1l-1.8 9H1.1M17.3 1.5h3.6l-1 4.9h4L25 1.5h3.5l-2.7 13h-3.5l1.1-5.6h-4.1l-1.2 5.6h-3.4M37.7 4.4c-.7-.3-1.6-.6-2.9-.6-1.4 0-2.5.2-2.5 1.3 0 1.8 5.1 1.2 5.1 5.1 0 3.6-3.3 4.5-6.4 4.5-1.3 0-2.9-.3-4-.7l.8-2.7c.7.4 2.1.7 3.2.7 1.3 0 2.3-.2 2.3-1.4 0-2-5.1-1.2-5.1-5.1 0-4 3.7-4.5 6.4-4.5 1.2 0 2.7.3 3.5.6"></path>
166+
<path fill="#005eb8"
167+
d="M3.9 1.5h4.4l2.6 9h.1l1.8-9h3.3l-2.8 13H9l-2.7-9h-.1l-1.8 9H1.1M17.3 1.5h3.6l-1 4.9h4L25 1.5h3.5l-2.7 13h-3.5l1.1-5.6h-4.1l-1.2 5.6h-3.4M37.7 4.4c-.7-.3-1.6-.6-2.9-.6-1.4 0-2.5.2-2.5 1.3 0 1.8 5.1 1.2 5.1 5.1 0 3.6-3.3 4.5-6.4 4.5-1.3 0-2.9-.3-4-.7l.8-2.7c.7.4 2.1.7 3.2.7 1.3 0 2.3-.2 2.3-1.4 0-2-5.1-1.2-5.1-5.1 0-4 3.7-4.5 6.4-4.5 1.2 0 2.7.3 3.5.6"></path>
139168
</svg>
140169
</div>
141170
</header>
@@ -152,7 +181,7 @@ <h1>Campaign Config Validator</h1>
152181
<label for="jsonfile">Upload Configuration File (JSON)</label>
153182
<span style="display:block; margin-bottom:10px; color:#4c6272; font-size:0.9rem;">Select the campaign configuration file you wish to validate against the rules API.</span>
154183

155-
<input type="file" id="jsonfile" accept=".json" disabled />
184+
<input type="file" id="jsonfile" accept=".json" disabled/>
156185

157186
<button id="run" class="nhs-button" disabled>
158187
Run Validation
@@ -200,11 +229,13 @@ <h3>Visualiser Output</h3>
200229
output.scrollTop = output.scrollHeight;
201230
}
202231

203-
function clearLog() { output.innerHTML = ""; } // Changed to innerHTML for spans
232+
function clearLog() {
233+
output.innerHTML = "";
234+
} // Changed to innerHTML for spans
204235

205-
function setStatus(msg, ready=false) {
236+
function setStatus(msg, ready = false) {
206237
statusText.innerHTML = ready ? `✅ ${msg}` : `⏳ ${msg}`;
207-
if(ready) {
238+
if (ready) {
208239
jsonInput.disabled = false;
209240
statusText.style.borderLeftColor = "#007f3b"; // Switch the status bar to green
210241
}
@@ -231,8 +262,8 @@ <h3>Visualiser Output</h3>
231262
async function main() {
232263
try {
233264
pyodide = await loadPyodide();
234-
pyodide.setStdout({ batched: (msg) => log(msg) });
235-
pyodide.setStderr({ batched: (msg) => log("ERR: " + msg) });
265+
pyodide.setStdout({batched: (msg) => log(msg)});
266+
pyodide.setStderr({batched: (msg) => log("ERR: " + msg)});
236267

237268
setStatus("Installing Python Libraries...");
238269
await pyodide.loadPackage("micropip");
@@ -249,10 +280,10 @@ <h3>Visualiser Output</h3>
249280
if (dir) {
250281
const parts = dir.split('/');
251282
let current = "";
252-
for(const part of parts) {
283+
for (const part of parts) {
253284
current += part + "/";
254-
if(!pyodide.FS.analyzePath(current.slice(0,-1)).exists) {
255-
pyodide.FS.mkdir(current.slice(0,-1));
285+
if (!pyodide.FS.analyzePath(current.slice(0, -1)).exists) {
286+
pyodide.FS.mkdir(current.slice(0, -1));
256287
}
257288
}
258289
}
@@ -268,6 +299,7 @@ <h3>Visualiser Output</h3>
268299
log(`❌ FATAL ERROR: ${err.message}`);
269300
}
270301
}
302+
271303
main();
272304

273305
let configContent = null;
@@ -284,6 +316,14 @@ <h3>Visualiser Output</h3>
284316
runBtn.addEventListener("click", async () => {
285317
clearLog();
286318
log("🚀 Validating Configuration...\n");
319+
320+
try {
321+
JSON.parse(configContent);
322+
} catch (err) {
323+
log("❌ JSON Error: " + err.message);
324+
return;
325+
}
326+
287327
pyodide.globals.set("raw_json", configContent);
288328

289329
try {

src/eligibility_signposting_api/model/campaign_config.py

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import json
4+
import re
45
import typing
56
from collections import Counter
67
from datetime import UTC, date, datetime
@@ -237,7 +238,7 @@ class StatusText(BaseModel):
237238
not_actionable: str | None = Field(None, alias="NotActionable")
238239
actionable: str | None = Field(None, alias="Actionable")
239240

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

242243

243244
class RuleEntry(BaseModel):
@@ -248,6 +249,14 @@ class RuleEntry(BaseModel):
248249
model_config = {"populate_by_name": True}
249250

250251

252+
class RulesMapper(RootModel[dict[str, RuleEntry]]):
253+
def get(self, key: str, default: RuleEntry | None = None) -> RuleEntry | None:
254+
return self.root.get(key, default)
255+
256+
def values(self) -> list[RuleEntry]:
257+
return list(self.root.values())
258+
259+
251260
class Iteration(BaseModel):
252261
id: IterationID = Field(..., alias="ID")
253262
version: IterationVersion = Field(..., alias="Version")
@@ -263,7 +272,7 @@ class Iteration(BaseModel):
263272
iteration_cohorts: list[IterationCohort] = Field(..., alias="IterationCohorts")
264273
iteration_rules: list[IterationRule] = Field(..., alias="IterationRules")
265274
actions_mapper: ActionsMapper = Field(..., alias="ActionsMapper")
266-
rules_mapper: dict[str, RuleEntry] | None = Field(None, alias="RulesMapper")
275+
rules_mapper: RulesMapper | None = Field(None, alias="RulesMapper")
267276
status_text: StatusText | None = Field(None, alias="StatusText")
268277

269278
model_config = {"populate_by_name": True, "arbitrary_types_allowed": True, "extra": "ignore"}
@@ -273,7 +282,18 @@ class Iteration(BaseModel):
273282
def parse_dates(cls, v: str | date) -> date:
274283
if isinstance(v, date):
275284
return v
276-
return datetime.strptime(v, "%Y%m%d").date() # noqa: DTZ007
285+
286+
v_str = str(v)
287+
288+
if not re.fullmatch(r"\d{8}", v_str):
289+
msg = f"Invalid format: {v_str}. Must be YYYYMMDD with 8 digits."
290+
raise ValueError(msg)
291+
292+
try:
293+
return datetime.strptime(v_str, "%Y%m%d").date() # noqa: DTZ007
294+
except ValueError as err:
295+
msg = f"Invalid date value: {v_str}. Must be a valid calendar date in YYYYMMDD format."
296+
raise ValueError(msg) from err
277297

278298
@field_serializer("iteration_date", when_used="always")
279299
@staticmethod
@@ -316,7 +336,18 @@ class CampaignConfig(BaseModel):
316336
def parse_dates(cls, v: str | date) -> date:
317337
if isinstance(v, date):
318338
return v
319-
return datetime.strptime(v, "%Y%m%d").date() # noqa: DTZ007
339+
340+
v_str = str(v)
341+
342+
if not re.fullmatch(r"\d{8}", v_str):
343+
msg = f"Invalid format: {v_str}. Must be YYYYMMDD with 8 digits."
344+
raise ValueError(msg)
345+
346+
try:
347+
return datetime.strptime(v_str, "%Y%m%d").date() # noqa: DTZ007
348+
except ValueError as err:
349+
msg = f"Invalid date value: {v_str}. Must be a valid calendar date in YYYYMMDD format."
350+
raise ValueError(msg) from err
320351

321352
@field_serializer("start_date", "end_date", when_used="always")
322353
@staticmethod

src/rules_validation_api/app.py

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,37 @@
44
import sys
55
from pathlib import Path
66

7+
from pydantic import ValidationError
8+
79
from rules_validation_api.validators.rules_validator import RulesValidation
810

911
logging.basicConfig(
10-
level=logging.INFO, # or DEBUG for more detail
12+
level=logging.INFO,
1113
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
1214
force=True,
1315
)
1416

15-
GREEN = "\033[92m" # pragma: no cover
16-
RESET = "\033[0m" # pragma: no cover
17-
YELLOW = "\033[93m" # pragma: no cover
18-
RED = "\033[91m" # pragma: no cover
17+
GREEN = "\033[92m"
18+
RESET = "\033[0m"
19+
YELLOW = "\033[93m"
20+
RED = "\033[91m"
21+
22+
23+
def refine_error(e: ValidationError) -> str:
24+
"""Return a very short, single-line error message."""
25+
lines = [f"Validation Error: {len(e.errors())} validation error(s)"]
26+
27+
for err in e.errors():
28+
loc = ".".join(str(x) for x in err["loc"])
29+
msg = err["msg"]
30+
type_ = err["type"]
1931

32+
lines.append(f"{loc} : {msg} [type={type_}]")
2033

21-
def main() -> None: # pragma: no cover
34+
return "\n".join(lines)
35+
36+
37+
def main() -> None:
2238
parser = argparse.ArgumentParser(description="Validate campaign configuration.")
2339
parser.add_argument("--config_path", required=True, help="Path to the campaign config JSON file")
2440
args = parser.parse_args()
@@ -28,9 +44,11 @@ def main() -> None: # pragma: no cover
2844
json_data = json.load(file)
2945
RulesValidation(**json_data)
3046
sys.stdout.write(f"{GREEN}Valid Config{RESET}\n")
31-
except ValueError as e:
32-
sys.stderr.write(f"{YELLOW}Validation Error:{RESET} {RED}{e}{RESET}\n")
47+
48+
except ValidationError as e:
49+
clean = refine_error(e)
50+
sys.stderr.write(f"{YELLOW}{clean}{RESET}\n")
3351

3452

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

0 commit comments

Comments
 (0)