Skip to content

Commit d28fcd4

Browse files
committed
fix validators and start writing tests
Signed-off-by: Isaac Milarsky <imilarsky@gmail.com>
1 parent 98db828 commit d28fcd4

File tree

2 files changed

+203
-99
lines changed

2 files changed

+203
-99
lines changed

backend/npdfhir/tests.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,17 @@
22
from rest_framework import status
33
from rest_framework.test import APITestCase, APIClient
44
from django.test.runner import DiscoverRunner
5+
from django.test import TestCase
56
from django.db import connection
67
from .cache import cacheData # I can't explain why, but we need to import cacheData here. I think we can remove this once we move to the docker db setup
78
from fhir.resources.bundle import Bundle
89
from pydantic import ValidationError
10+
from .models import Nucc, C80PracticeCodes
11+
from .validators import NPDValueSet, NPDPractitioner
12+
from fhir.resources.valueset import ValueSet
13+
from fhir.resources.practitioner import Practitioner
14+
15+
916

1017
def get_female_npis(npi_list):
1118
"""
@@ -249,3 +256,98 @@ def test_retrieve_nonexistent(self):
249256
url = reverse("fhir-practitioner-detail", args=[999999])
250257
response = self.client.get(url)
251258
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
259+
260+
261+
class NPDValueSetValidatorTests(TestCase):
262+
263+
def test_verify_codes_valid(self):
264+
"""✅ Should pass when all codes exist in the DB."""
265+
266+
data = {
267+
"resourceType" : "ValueSet",
268+
"id" : "FHIR-version",
269+
"meta" : {
270+
"lastUpdated" : "2025-10-16T16:45:52.699+00:00",
271+
"profile" : ["http://hl7.org/fhir/StructureDefinition/shareablevalueset"]
272+
},
273+
"text" : {
274+
"status" : "generated",
275+
"div" : "<div xmlns=\"http://www.w3.org/1999/xhtml\"><p class=\"res-header-id\"><b>Generated Narrative: ValueSet FHIR-version</b></p><a name=\"FHIR-version\"> </a><a name=\"hcFHIR-version\"> </a><div style=\"display: inline-block; background-color: #d9e0e7; padding: 6px; margin: 4px; border: 1px solid #8da1b4; border-radius: 5px; line-height: 60%\"><p style=\"margin-bottom: 0px\"/><p style=\"margin-bottom: 0px\">Profile: <a href=\"shareablevalueset.html\">Shareable ValueSet</a></p></div><ul><li>Include all codes defined in <a href=\"codesystem-FHIR-version.html\"><code>http://hl7.org/fhir/FHIR-version</code></a> version <span title=\"Version is not explicitly stated, which means it is fixed to the version provided in this specification\">&#x1F4E6;6.0.0-ballot3</span></li></ul></div>"
276+
},
277+
"extension" : [{
278+
"url" : "http://hl7.org/fhir/StructureDefinition/structuredefinition-wg",
279+
"valueCode" : "fhir"
280+
},
281+
{
282+
"url" : "http://hl7.org/fhir/StructureDefinition/structuredefinition-standards-status",
283+
"valueCode" : "normative"
284+
},
285+
{
286+
"url" : "http://hl7.org/fhir/StructureDefinition/structuredefinition-normative-version",
287+
"valueCode" : "4.0.0"
288+
},
289+
{
290+
"url" : "http://hl7.org/fhir/StructureDefinition/structuredefinition-fmm",
291+
"valueInteger" : 5
292+
}],
293+
"url" : "http://hl7.org/fhir/ValueSet/FHIR-version",
294+
"identifier" : [{
295+
"system" : "urn:ietf:rfc:3986",
296+
"value" : "urn:oid:2.16.840.1.113883.4.642.3.1309"
297+
}],
298+
"version" : "6.0.0-ballot3",
299+
"name" : "FHIRVersion",
300+
"title" : "FHIRVersion",
301+
"status" : "active",
302+
"experimental" : False,
303+
"date" : "2025-10-16T16:45:52+00:00",
304+
"publisher" : "HL7 International / FHIR Infrastructure",
305+
"contact" : [{
306+
"telecom" : [{
307+
"system" : "url",
308+
"value" : "http://www.hl7.org/Special/committees/fiwg"
309+
}]
310+
}],
311+
"description" : "All published FHIR Versions.",
312+
"jurisdiction" : [{
313+
"coding" : [{
314+
"system" : "http://unstats.un.org/unsd/methods/m49/m49.htm",
315+
"code" : "001",
316+
"display" : "World"
317+
}]
318+
}],
319+
"immutable" : True,
320+
"compose": {
321+
"include": [
322+
{
323+
"system": "http://nucc.org/provider-taxonomy",
324+
"concept": [{"code": "101Y00000X"}],
325+
},
326+
{
327+
"system": "http://snomed.info/sct",
328+
"concept": [{"code": "419772000"}],
329+
},
330+
]
331+
},
332+
}
333+
334+
model = NPDValueSet(**data)
335+
336+
self.assertIsInstance(model, ValueSet)
337+
338+
def test_verify_codes_invalid(self):
339+
"""🚫 Should raise ValueError when a code is not found."""
340+
data = {
341+
"resourceType": "ValueSet",
342+
"compose": {
343+
"include": [
344+
{
345+
"system": "http://nucc.org/provider-taxonomy",
346+
"concept": [{"code": "BAD_CODE"}],
347+
}
348+
]
349+
},
350+
}
351+
352+
with self.assertRaises(ValidationError) as ctx:
353+
NPDValueSet(**data)

backend/npdfhir/validators.py

Lines changed: 101 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -9,144 +9,146 @@
99
from fhir.resources.valueset import ValueSet
1010
from fhir.resources.practitioner import Practitioner
1111
from pydantic import ValidationError
12-
from pydantic.functional_validators import model_validator
12+
from pydantic import model_validator
1313
from django.db import connection
1414
from .models import Nucc, C80PracticeCodes
1515

1616

17-
@model_validator(mode="after")
18-
def verify_codes(self):
19-
"""
20-
Verifies the code items present in the valueset
21-
22-
Args:
23-
self (ValueSet): Pydantic Object representing a FHIR ValueSet
17+
class NPDValueSet(ValueSet):
18+
@model_validator(mode="after")
19+
def verify_codes(self):
20+
"""
21+
Verifies the code items present in the valueset
2422
25-
Raises:
26-
ValidationError: raises a validationError if the codes are not part of a
27-
known valid code
28-
"""
23+
Args:
24+
self (ValueSet): Pydantic Object representing a FHIR ValueSet
2925
30-
def get_nucc_codes_from_db():
31-
"""
32-
Function that fetches nucc codes from the db
26+
Raises:
27+
ValidationError: raises a validationError if the codes are not part of a
28+
known valid code
3329
"""
3430

35-
data = dict(
36-
Nucc.objects.values_list("code", "display_name")
37-
)
38-
39-
return data
31+
def get_nucc_codes_from_db():
32+
"""
33+
Function that fetches nucc codes from the db
34+
"""
4035

36+
data = dict(
37+
Nucc.objects.values_list("code", "display_name")
38+
)
4139

42-
def get_c80_codes_from_db():
43-
"""
44-
Function that fetches c80 codes from db
45-
"""
40+
return data
4641

47-
data = dict(
48-
C80PracticeCodes.objects.values_list("code", "display_name")
49-
)
5042

51-
return data
43+
def get_c80_codes_from_db():
44+
"""
45+
Function that fetches c80 codes from db
46+
"""
5247

53-
nucc_codes = get_nucc_codes_from_db()
54-
c80_codes = get_c80_codes_from_db()
48+
data = dict(
49+
C80PracticeCodes.objects.values_list("code", "display_name")
50+
)
5551

56-
for include_index, code_item in enumerate(self.compose['include']):
57-
#Check if code in ValueSet is part of nucc
52+
return data
5853

59-
for concept_index, code_value in enumerate(code_item['concept']):
60-
try:
61-
if "nucc.org/provider-taxonomy" in code_item['system']:
62-
assert code_value['code'] in nucc_codes
63-
elif "snomed.info/sct" in code_item['system']:
64-
assert code_value['code'] in c80_codes
65-
except AssertionError as e:
66-
raise ValueError(
67-
f"Code {code_value['code']} is not a valid {code_value['system']} code!"
68-
f"(at include[{include_index}].concept[{concept_index}])"
69-
) from e
70-
71-
return self
54+
nucc_codes = get_nucc_codes_from_db()
55+
c80_codes = get_c80_codes_from_db()
7256

73-
#ValueSet.add_root_validator(verify_codes, pre=False)
74-
ValueSet.model_rebuild(validators=[verify_codes])
57+
for include_index, code_item in enumerate(self.compose.include):
58+
#Check if code in ValueSet is part of nucc
7559

76-
@model_validator(mode="after")
77-
def verify_identifier_code_from_data(self):
78-
"""
79-
Iterates through json data and verifies the identifiers present
60+
for concept_index, code_value in enumerate(code_item.concept):
61+
try:
62+
if "nucc.org/provider-taxonomy" in code_item.system:
63+
assert code_value.code in nucc_codes
64+
elif "snomed.info/sct" in code_item.system:
65+
assert code_value.code in c80_codes
66+
except AssertionError as e:
67+
raise ValueError(
68+
f"Code {code_value.code} is not a valid {code_item.system} code!"
69+
f"(at include[{include_index}].concept[{concept_index}])"
70+
) from e
71+
72+
return self
8073

81-
Args:
82-
data (dict): json dictionary representing fhir data
83-
"""
74+
#ValueSet.add_root_validator(verify_codes, pre=False)
75+
#ValueSet.model_rebuild(validators=[verify_codes])
8476

85-
def verify_npi(npi,position):
77+
class NPDPractitioner(Practitioner):
78+
@model_validator(mode="after")
79+
def verify_identifier_code_from_data(self):
8680
"""
87-
Checks the npi code against various checks and throws errors if they fail.
81+
Iterates through json data and verifies the identifiers present
8882
8983
Args:
90-
npi (str): npi identifier string
91-
position (int): index where npi is found in the json
92-
93-
Raises:
94-
ValidationError: Error thrown when npi value is out of range
95-
ValidationError: Error thrown when npi value fails luhn algo
84+
data (dict): json dictionary representing fhir data
9685
"""
9786

98-
def is_valid_npi_format(npi_value):
87+
def verify_npi(npi,position):
9988
"""
100-
Checks npi value string for correct range and digits
89+
Checks the npi code against various checks and throws errors if they fail.
10190
10291
Args:
103-
npi_value (str): npi identifier string
92+
npi (str): npi identifier string
93+
position (int): index where npi is found in the json
10494
105-
Returns:
106-
bool: True if value is valid
95+
Raises:
96+
ValidationError: Error thrown when npi value is out of range
97+
ValidationError: Error thrown when npi value fails luhn algo
10798
"""
10899

109-
digits_only = re.sub(r'\D', '', str(npi_value))
110-
npi_num = int(digits_only)
111-
return 999999999 <= npi_num <= 10000000000
100+
def is_valid_npi_format(npi_value):
101+
"""
102+
Checks npi value string for correct range and digits
112103
113-
#NPI luhn algo defined here:
114-
#https://www.cms.gov/Regulations-and-Guidance/Administrative-Simplification/NationalProvIdentStand/Downloads/NPIcheckdigit.pdf
115-
def npi_check_luhn_algorithm(npi_value):
116-
"""
117-
Checks npi value string based on given luhn algorithm
104+
Args:
105+
npi_value (str): npi identifier string
118106
119-
Transforms every other digit and sums them together plus 24
107+
Returns:
108+
bool: True if value is valid
109+
"""
120110

121-
If the result is divisible by 10 then the npi value is valid
111+
digits_only = re.sub(r'\D', '', str(npi_value))
112+
npi_num = int(digits_only)
113+
return 999999999 <= npi_num <= 10000000000
122114

123-
Args:
124-
npi_value (str): npi identifier string
125-
"""
115+
#NPI luhn algo defined here:
116+
#https://www.cms.gov/Regulations-and-Guidance/Administrative-Simplification/NationalProvIdentStand/Downloads/NPIcheckdigit.pdf
117+
def npi_check_luhn_algorithm(npi_value):
118+
"""
119+
Checks npi value string based on given luhn algorithm
120+
121+
Transforms every other digit and sums them together plus 24
122+
123+
If the result is divisible by 10 then the npi value is valid
124+
125+
Args:
126+
npi_value (str): npi identifier string
127+
"""
126128

127-
def transform(d):
128-
return d * 2 if d < 5 else d * 2 - 9
129+
def transform(d):
130+
return d * 2 if d < 5 else d * 2 - 9
129131

130-
digits_only = re.sub(r'\D', '', str(npi_value))
132+
digits_only = re.sub(r'\D', '', str(npi_value))
131133

132-
digits = [int(ch) for ch in digits_only[:10]]
134+
digits = [int(ch) for ch in digits_only[:10]]
133135

134-
total = sum(
135-
transform(d) if i % 2 == 0 else d
136-
for i, d in enumerate(digits)
137-
) + 24
136+
total = sum(
137+
transform(d) if i % 2 == 0 else d
138+
for i, d in enumerate(digits)
139+
) + 24
138140

139-
return total % 10 == 0
141+
return total % 10 == 0
140142

141-
if not is_valid_npi_format(npi):
142-
raise ValueError(f"NPI '{npi}' (identifier[{position}]) invalid format")
143-
if not npi_check_luhn_algorithm(npi):
144-
raise ValueError(f"NPI '{npi}' (identifier[{position}]) failed Luhn check")
143+
if not is_valid_npi_format(npi):
144+
raise ValueError(f"NPI '{npi}' (identifier[{position}]) invalid format")
145+
if not npi_check_luhn_algorithm(npi):
146+
raise ValueError(f"NPI '{npi}' (identifier[{position}]) failed Luhn check")
145147

146-
for identifier_index, identifier in enumerate(self.identifier):
147-
if "hl7.org/fhir/sid/us-npi" in identifier['system']:
148-
verify_npi(identifier['value'],identifier_index)
149-
150-
return self
148+
for identifier_index, identifier in enumerate(self.identifier):
149+
if "hl7.org/fhir/sid/us-npi" in identifier['system']:
150+
verify_npi(identifier['value'],identifier_index)
151+
152+
return self
151153

152-
Practitioner.model_rebuild(validators=[verify_identifier_code_from_data])
154+
#Practitioner.model_rebuild(validators=[verify_identifier_code_from_data])

0 commit comments

Comments
 (0)