mphapi 1.9.1__tar.gz → 1.12.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {mphapi-1.9.1 → mphapi-1.12.1}/PKG-INFO +4 -3
- {mphapi-1.9.1 → mphapi-1.12.1}/mphapi/client.py +141 -17
- mphapi-1.12.1/mphapi/client_test.py +114 -0
- mphapi-1.12.1/mphapi/credentials.py +256 -0
- mphapi-1.12.1/mphapi/date.py +125 -0
- mphapi-1.12.1/mphapi/env.py +24 -0
- {mphapi-1.9.1 → mphapi-1.12.1}/mphapi/pricing.py +136 -0
- mphapi-1.12.1/mphapi/py.typed +0 -0
- {mphapi-1.9.1 → mphapi-1.12.1}/pyproject.toml +8 -2
- mphapi-1.9.1/mphapi/client_test.py +0 -38
- mphapi-1.9.1/mphapi/date.py +0 -51
- {mphapi-1.9.1 → mphapi-1.12.1}/mphapi/__init__.py +0 -0
- {mphapi-1.9.1 → mphapi-1.12.1}/mphapi/claim.py +0 -0
- {mphapi-1.9.1 → mphapi-1.12.1}/mphapi/fields.py +0 -0
- {mphapi-1.9.1 → mphapi-1.12.1}/mphapi/response.py +0 -0
- {mphapi-1.9.1 → mphapi-1.12.1}/mphapi/snapshots/client_test/test_client/hcfa.json +0 -0
- {mphapi-1.9.1 → mphapi-1.12.1}/mphapi/snapshots/client_test/test_client/inpatient.json +0 -0
- {mphapi-1.9.1 → mphapi-1.12.1}/mphapi/snapshots/client_test/test_client/outpatient.json +0 -0
- {mphapi-1.9.1 → mphapi-1.12.1}/mphapi/testdata/hcfa.json +0 -0
- {mphapi-1.9.1 → mphapi-1.12.1}/mphapi/testdata/inpatient.json +0 -0
- {mphapi-1.9.1 → mphapi-1.12.1}/mphapi/testdata/outpatient.json +0 -0
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
2
|
Name: mphapi
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.12.1
|
|
4
4
|
Summary: A Python interface to the MyPriceHealth API
|
|
5
5
|
Author: David Archibald
|
|
6
6
|
Author-email: davidarchibald@myprice.health
|
|
7
7
|
Requires-Python: >=3.12,<4.0
|
|
8
8
|
Classifier: Programming Language :: Python :: 3
|
|
9
9
|
Classifier: Programming Language :: Python :: 3.12
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
10
11
|
Requires-Dist: pydantic (>=2.6.4,<3.0.0)
|
|
11
|
-
Requires-Dist:
|
|
12
|
+
Requires-Dist: python-dotenv (>=1.1.1,<2.0.0)
|
|
12
13
|
Requires-Dist: requests (>=2.31.0,<3.0.0)
|
|
@@ -5,6 +5,7 @@ import requests
|
|
|
5
5
|
from pydantic import BaseModel, StrictBool, TypeAdapter
|
|
6
6
|
|
|
7
7
|
from .claim import Claim, RateSheet
|
|
8
|
+
from .credentials import Credentials, get_credentials
|
|
8
9
|
from .fields import camel_case_model_config, field_name
|
|
9
10
|
from .response import Response, Responses
|
|
10
11
|
|
|
@@ -64,43 +65,89 @@ class PriceConfig(BaseModel):
|
|
|
64
65
|
"""set to true to return partially repriced claims. This can be useful to get pricing on non-erroring line items, but should be used with caution"""
|
|
65
66
|
|
|
66
67
|
|
|
67
|
-
# It may be a bit jarring to see
|
|
68
|
+
# It may be a bit jarring to see these imports not at the top of the file. This is
|
|
68
69
|
# intentional as `.pricing` depends on `PriceConfig`.
|
|
70
|
+
from .pricing import ClaimStatus # noqa: E402
|
|
69
71
|
from .pricing import Pricing # noqa: E402
|
|
70
72
|
|
|
71
73
|
|
|
72
74
|
class Client:
|
|
73
|
-
|
|
75
|
+
api_url: str
|
|
74
76
|
headers: Header
|
|
75
77
|
|
|
76
|
-
def __init__(
|
|
77
|
-
|
|
78
|
+
def __init__(
|
|
79
|
+
self,
|
|
80
|
+
apiKey: str,
|
|
81
|
+
isTest: bool = False,
|
|
82
|
+
api_url: str | None = None,
|
|
83
|
+
app_url: str | None = None,
|
|
84
|
+
app_api_key: str | None = None,
|
|
85
|
+
app_referer: str | None = None,
|
|
86
|
+
app_credentials: Credentials | None = None,
|
|
87
|
+
):
|
|
88
|
+
if api_url is None:
|
|
89
|
+
if isTest:
|
|
90
|
+
self.api_url = "https://api-test.myprice.health"
|
|
91
|
+
else:
|
|
92
|
+
self.api_url = "https://api.myprice.health"
|
|
93
|
+
else:
|
|
94
|
+
self.api_url = api_url
|
|
95
|
+
|
|
96
|
+
if app_url is None:
|
|
78
97
|
if isTest:
|
|
79
|
-
self.
|
|
98
|
+
self.app_url = "https://app-test.myprice.health"
|
|
80
99
|
else:
|
|
81
|
-
self.
|
|
100
|
+
self.app_url = "https://app.myprice.health"
|
|
82
101
|
else:
|
|
83
|
-
self.
|
|
102
|
+
self.app_url = app_url
|
|
103
|
+
|
|
104
|
+
if (app_url is None) != (app_api_key is None) or (app_api_key is None) != (
|
|
105
|
+
app_referer is None
|
|
106
|
+
):
|
|
107
|
+
raise Exception(
|
|
108
|
+
"app_url, app_api_key, and app_referer must be set in tandem"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
self.app_api_key = app_api_key
|
|
112
|
+
self.app_credentials = app_credentials
|
|
113
|
+
self.app_referer = app_referer
|
|
114
|
+
|
|
115
|
+
if (
|
|
116
|
+
self.app_credentials is None
|
|
117
|
+
and app_api_key is not None
|
|
118
|
+
and app_referer is not None
|
|
119
|
+
):
|
|
120
|
+
self.app_credentials = get_credentials(app_api_key, app_referer)
|
|
84
121
|
|
|
85
122
|
self.headers = {"x-api-key": apiKey}
|
|
86
123
|
|
|
124
|
+
def _get_id_token(self) -> str:
|
|
125
|
+
if self.app_credentials is None:
|
|
126
|
+
raise Exception("App credentials must be set to run this!")
|
|
127
|
+
|
|
128
|
+
refreshed = self.app_credentials.refresh_if_needed()
|
|
129
|
+
if refreshed is not None:
|
|
130
|
+
self.app_credentials = refreshed
|
|
131
|
+
|
|
132
|
+
return self.app_credentials.id_token
|
|
133
|
+
|
|
87
134
|
def _do_request(
|
|
88
135
|
self,
|
|
89
|
-
|
|
136
|
+
url: str,
|
|
90
137
|
json: Any | None,
|
|
91
138
|
method: str = "POST",
|
|
92
139
|
headers: Header = {},
|
|
93
140
|
) -> requests.Response:
|
|
94
141
|
return requests.request(
|
|
95
142
|
method,
|
|
96
|
-
|
|
143
|
+
url,
|
|
97
144
|
json=json,
|
|
98
145
|
headers={**self.headers, **headers},
|
|
99
146
|
)
|
|
100
147
|
|
|
101
148
|
def _receive_response[Model: BaseModel](
|
|
102
149
|
self,
|
|
103
|
-
|
|
150
|
+
url: str,
|
|
104
151
|
body: BaseModel,
|
|
105
152
|
response_model: type[Model],
|
|
106
153
|
method: str = "POST",
|
|
@@ -115,7 +162,7 @@ class Client:
|
|
|
115
162
|
"""
|
|
116
163
|
|
|
117
164
|
response = self._do_request(
|
|
118
|
-
|
|
165
|
+
url,
|
|
119
166
|
body.model_dump(mode="json", by_alias=True, exclude_none=True),
|
|
120
167
|
method,
|
|
121
168
|
headers,
|
|
@@ -127,9 +174,44 @@ class Client:
|
|
|
127
174
|
.result()
|
|
128
175
|
)
|
|
129
176
|
|
|
177
|
+
def _receive_api_response[Model: BaseModel](
|
|
178
|
+
self,
|
|
179
|
+
url: str,
|
|
180
|
+
body: BaseModel,
|
|
181
|
+
response_model: type[Model],
|
|
182
|
+
method: str = "POST",
|
|
183
|
+
headers: Header = {},
|
|
184
|
+
) -> Model:
|
|
185
|
+
return self._receive_response(
|
|
186
|
+
urllib.parse.urljoin(self.api_url, url),
|
|
187
|
+
body,
|
|
188
|
+
response_model,
|
|
189
|
+
method,
|
|
190
|
+
headers,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
def _receive_app_response[Model: BaseModel](
|
|
194
|
+
self,
|
|
195
|
+
url: str,
|
|
196
|
+
body: BaseModel,
|
|
197
|
+
response_model: type[Model],
|
|
198
|
+
method: str = "POST",
|
|
199
|
+
headers: Header = {},
|
|
200
|
+
) -> Model:
|
|
201
|
+
id_token = self._get_id_token()
|
|
202
|
+
|
|
203
|
+
# TODO: Handle refreshing revoked credentials. It's unclear what the error will be unfortunately.
|
|
204
|
+
return self._receive_response(
|
|
205
|
+
urllib.parse.urljoin(self.app_url, url),
|
|
206
|
+
body,
|
|
207
|
+
response_model,
|
|
208
|
+
method,
|
|
209
|
+
{"Authorization": f"Bearer {id_token}", **headers},
|
|
210
|
+
)
|
|
211
|
+
|
|
130
212
|
def _receive_responses[Model: BaseModel](
|
|
131
213
|
self,
|
|
132
|
-
|
|
214
|
+
url: str,
|
|
133
215
|
body: Sequence[BaseModel],
|
|
134
216
|
response_model: type[Model],
|
|
135
217
|
method: str = "POST",
|
|
@@ -144,7 +226,7 @@ class Client:
|
|
|
144
226
|
"""
|
|
145
227
|
|
|
146
228
|
response = self._do_request(
|
|
147
|
-
|
|
229
|
+
url,
|
|
148
230
|
TypeAdapter(type(body)).dump_python(
|
|
149
231
|
body, mode="json", by_alias=True, exclude_none=True
|
|
150
232
|
),
|
|
@@ -158,6 +240,41 @@ class Client:
|
|
|
158
240
|
.results()
|
|
159
241
|
)
|
|
160
242
|
|
|
243
|
+
def _receive_api_responses[Model: BaseModel](
|
|
244
|
+
self,
|
|
245
|
+
url: str,
|
|
246
|
+
body: Sequence[BaseModel],
|
|
247
|
+
response_model: type[Model],
|
|
248
|
+
method: str = "POST",
|
|
249
|
+
headers: Header = {},
|
|
250
|
+
) -> list[Model]:
|
|
251
|
+
return self._receive_responses(
|
|
252
|
+
urllib.parse.urljoin(self.api_url, url),
|
|
253
|
+
body,
|
|
254
|
+
response_model,
|
|
255
|
+
method,
|
|
256
|
+
headers,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
def _receive_app_responses[Model: BaseModel](
|
|
260
|
+
self,
|
|
261
|
+
url: str,
|
|
262
|
+
body: Sequence[BaseModel],
|
|
263
|
+
response_model: type[Model],
|
|
264
|
+
method: str = "POST",
|
|
265
|
+
headers: Header = {},
|
|
266
|
+
) -> list[Model]:
|
|
267
|
+
id_token = self._get_id_token()
|
|
268
|
+
|
|
269
|
+
# TODO: Handle refreshing revoked credentials. It's unclear what the error will be unfortunately.
|
|
270
|
+
return self._receive_responses(
|
|
271
|
+
urllib.parse.urljoin(self.app_url, url),
|
|
272
|
+
body,
|
|
273
|
+
response_model,
|
|
274
|
+
method,
|
|
275
|
+
{"Authorization": f"Bearer {id_token}", **headers},
|
|
276
|
+
)
|
|
277
|
+
|
|
161
278
|
def estimate_rate_sheet(self, *inputs: RateSheet) -> list[Pricing]:
|
|
162
279
|
"""
|
|
163
280
|
Raises:
|
|
@@ -167,7 +284,7 @@ class Client:
|
|
|
167
284
|
The error returned when the api returns an error.
|
|
168
285
|
"""
|
|
169
286
|
|
|
170
|
-
return self.
|
|
287
|
+
return self._receive_api_responses(
|
|
171
288
|
"/v1/medicare/estimate/rate-sheet",
|
|
172
289
|
inputs,
|
|
173
290
|
Pricing,
|
|
@@ -182,7 +299,7 @@ class Client:
|
|
|
182
299
|
The error returned when the api returns an error.
|
|
183
300
|
"""
|
|
184
301
|
|
|
185
|
-
return self.
|
|
302
|
+
return self._receive_api_responses(
|
|
186
303
|
"/v1/medicare/estimate/claims",
|
|
187
304
|
inputs,
|
|
188
305
|
Pricing,
|
|
@@ -199,7 +316,7 @@ class Client:
|
|
|
199
316
|
"""
|
|
200
317
|
|
|
201
318
|
return self._receive_response(
|
|
202
|
-
"/v1/medicare/price/claim",
|
|
319
|
+
urllib.parse.urljoin(self.api_url, "/v1/medicare/price/claim"),
|
|
203
320
|
input,
|
|
204
321
|
Pricing,
|
|
205
322
|
headers=self._get_price_headers(config),
|
|
@@ -214,7 +331,7 @@ class Client:
|
|
|
214
331
|
The error returned when the api returns an error.
|
|
215
332
|
"""
|
|
216
333
|
|
|
217
|
-
return self.
|
|
334
|
+
return self._receive_api_responses(
|
|
218
335
|
"/v1/medicare/price/claims",
|
|
219
336
|
input,
|
|
220
337
|
Pricing,
|
|
@@ -257,3 +374,10 @@ class Client:
|
|
|
257
374
|
headers["disable-machine-learning-estimates"] = "true"
|
|
258
375
|
|
|
259
376
|
return headers
|
|
377
|
+
|
|
378
|
+
def insert_claim_status(self, claim_id: str, claim_status: ClaimStatus) -> None:
|
|
379
|
+
self._receive_app_response(
|
|
380
|
+
f"/v1/claim/{claim_id}/status",
|
|
381
|
+
claim_status,
|
|
382
|
+
BaseModel,
|
|
383
|
+
)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
# This import annoys Pylance for some reason.
|
|
9
|
+
from pytest_snapshot.plugin import Snapshot # type: ignore
|
|
10
|
+
|
|
11
|
+
from .client import Claim, Client, PriceConfig
|
|
12
|
+
from .credentials import Credentials, sign_in
|
|
13
|
+
from .env import load_env
|
|
14
|
+
from .pricing import ClaimStatus, PricedService, Pricing, status_new
|
|
15
|
+
from .response import ResponseError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.fixture(autouse=True)
|
|
19
|
+
def run_around_tests():
|
|
20
|
+
load_env()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_client(snapshot: Snapshot):
|
|
24
|
+
api_key = os.getenv("MPH_API_KEY")
|
|
25
|
+
if api_key is None:
|
|
26
|
+
raise EnvironmentError("MPH_API_KEY must be set")
|
|
27
|
+
|
|
28
|
+
api_url = os.getenv("API_URL")
|
|
29
|
+
app_url = os.getenv("APP_URL")
|
|
30
|
+
app_api_key = os.getenv("FIREBASE_API_KEY")
|
|
31
|
+
if app_api_key is None:
|
|
32
|
+
raise Exception("FIREBASE_API_KEY must be set")
|
|
33
|
+
|
|
34
|
+
app_referer = os.getenv("FIREBASE_REFERER")
|
|
35
|
+
if app_referer is None:
|
|
36
|
+
raise Exception("FIREBASE_REFERER must be set")
|
|
37
|
+
|
|
38
|
+
app_credentials = Credentials(
|
|
39
|
+
api_key=app_api_key,
|
|
40
|
+
referer=app_referer,
|
|
41
|
+
credentials_path=Path("fake-credentials-path"),
|
|
42
|
+
email="test-user@mypricehealth.com",
|
|
43
|
+
# An id token with some minimal user info.
|
|
44
|
+
# Will not work if tested against a service that expects a real id token.
|
|
45
|
+
id_token="eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImFhYSI6dHJ1ZX0.eyJpc3MiOiJ0ZXN0IGlzc3VlciIsImVtYWlsIjoidGVzdC11c2VyQG15cHJpY2VoZWFsdGguY29tIiwic3ViIjoidGVzdCBzdWJqZWN0IiwiYXVkIjoidGVzdCBhdWRpZW5jZSIsImV4cCI6MCwiaWF0IjowLCJsb2dpbl9pZCI6IjEyMzQ1Njc4OTAiLCJyb2xlcyI6WyJBZG1pbiJdfQ.iCx0YI9L9qo7KCkIq1w58C0mBdiCDhikPZsM3Mx78j11Ln3WxWpAnm0IomIRfrDVU1JczW8OjSj5os7igp9fYufZwObLV5N5eDd9LmYtXs2EcO8EFHcM7HeHx6lnkT1GxX-4J_946WbIdHYnpLyoXLZF_MZNfEwsN1UBG6YdaRcwfhF9vJHt6YM7-IoOcHvdLiEWv06Y9eC0v--_R_x8GwjSrE0Z9EyZw3hMz94QZ5VgUf8e89NexeVGIoD4pUOHuYmNIx1ca_UTIOg81exhhnh4g190jUSer5582YJc5Hx7gts3DyizRmAK8Glcwhnc7WKvZDQaSjtbHzIARnUKtw",
|
|
46
|
+
refresh_token="fake-refresh-token",
|
|
47
|
+
expires_at=sys.float_info.max,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
client = Client(
|
|
51
|
+
api_key,
|
|
52
|
+
api_url=api_url,
|
|
53
|
+
app_url=app_url,
|
|
54
|
+
app_api_key=app_api_key,
|
|
55
|
+
app_referer=app_referer,
|
|
56
|
+
app_credentials=app_credentials,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
config = PriceConfig(
|
|
60
|
+
is_commercial=True,
|
|
61
|
+
disable_cost_based_reimbursement=False,
|
|
62
|
+
use_commercial_synthetic_for_not_allowed=True,
|
|
63
|
+
use_drg_from_grouper=False,
|
|
64
|
+
use_best_drg_price=True,
|
|
65
|
+
override_threshold=300,
|
|
66
|
+
include_edits=True,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
tests = ["hcfa", "inpatient", "outpatient"]
|
|
70
|
+
|
|
71
|
+
for test in tests:
|
|
72
|
+
with open(f"testdata/{test}.json", "r") as f:
|
|
73
|
+
data = json.load(f)
|
|
74
|
+
|
|
75
|
+
claim = Claim.model_validate(data)
|
|
76
|
+
pricing = client.price(config, claim)
|
|
77
|
+
|
|
78
|
+
snapshot.assert_match(pricing.model_dump_json(indent=4), f"{test}.json")
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
client.insert_claim_status(
|
|
82
|
+
"123",
|
|
83
|
+
ClaimStatus(
|
|
84
|
+
step=status_new.step,
|
|
85
|
+
status=status_new.status,
|
|
86
|
+
pricing=Pricing(services=[PricedService(line_number="6789")]),
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
except ResponseError as response_error:
|
|
90
|
+
# The claim and line item won't exist in the database
|
|
91
|
+
assert (
|
|
92
|
+
response_error.detail
|
|
93
|
+
== "expected to insert 1 line item repricing rows but inserted 0"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_signin():
|
|
100
|
+
app_api_key = os.getenv("FIREBASE_API_KEY")
|
|
101
|
+
if app_api_key is None:
|
|
102
|
+
raise Exception("FIREBASE_API_KEY must be set")
|
|
103
|
+
|
|
104
|
+
test_user = os.getenv("FIREBASE_TEST_USER")
|
|
105
|
+
if test_user is None:
|
|
106
|
+
pytest.skip("Skipping because FIREBASE_TEST_USER is not set")
|
|
107
|
+
|
|
108
|
+
test_password = os.getenv("FIREBASE_TEST_PASSWORD")
|
|
109
|
+
if test_password is None:
|
|
110
|
+
pytest.skip("Skipping because FIREBASE_TEST_PASSWORD is not set")
|
|
111
|
+
|
|
112
|
+
credentials = sign_in(app_api_key, test_user, test_password)
|
|
113
|
+
|
|
114
|
+
assert credentials.email == test_user
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import getpass
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import time
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
from pydantic import BaseModel, Field, RootModel
|
|
9
|
+
|
|
10
|
+
from .fields import camel_case_model_config
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RawCredentials(BaseModel):
|
|
14
|
+
model_config = camel_case_model_config
|
|
15
|
+
|
|
16
|
+
email: str
|
|
17
|
+
id_token: str
|
|
18
|
+
refresh_token: str
|
|
19
|
+
expires_at: float
|
|
20
|
+
|
|
21
|
+
def store(
|
|
22
|
+
self, api_key: str, referer: str, credentials_path: Path
|
|
23
|
+
) -> "Credentials":
|
|
24
|
+
stored_credentials = StoredCredentials(
|
|
25
|
+
api_key=api_key,
|
|
26
|
+
referer=referer,
|
|
27
|
+
email=self.email,
|
|
28
|
+
id_token=self.id_token,
|
|
29
|
+
refresh_token=self.refresh_token,
|
|
30
|
+
expires_at=self.expires_at,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
os.makedirs(os.path.dirname(credentials_path), exist_ok=True)
|
|
34
|
+
|
|
35
|
+
with credentials_path.open("w+") as f:
|
|
36
|
+
json.dump(
|
|
37
|
+
stored_credentials.model_dump(
|
|
38
|
+
mode="json", by_alias=True, exclude_none=True
|
|
39
|
+
),
|
|
40
|
+
f,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
return Credentials(
|
|
44
|
+
referer=referer,
|
|
45
|
+
credentials_path=credentials_path,
|
|
46
|
+
api_key=api_key,
|
|
47
|
+
email=stored_credentials.email,
|
|
48
|
+
id_token=stored_credentials.id_token,
|
|
49
|
+
refresh_token=stored_credentials.refresh_token,
|
|
50
|
+
expires_at=stored_credentials.expires_at,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class StoredCredentials(RawCredentials):
|
|
55
|
+
api_key: str
|
|
56
|
+
referer: str
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Credentials(StoredCredentials):
|
|
60
|
+
credentials_path: Path = Field(..., exclude=True)
|
|
61
|
+
|
|
62
|
+
def refresh_if_needed(self) -> "Credentials | None":
|
|
63
|
+
# # There's more than 5 minutes until the credentials need refreshing. Don't bother.
|
|
64
|
+
if self.expires_at > time.time() + 5 * 60:
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
new_credentials = refresh_token(self.api_key, self.referer, self.refresh_token)
|
|
68
|
+
return new_credentials.store(self.api_key, self.referer, self.credentials_path)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class GoogleException(Exception):
|
|
72
|
+
def __init__(self, message: str):
|
|
73
|
+
self.message = message
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class GoogleError(BaseModel):
|
|
77
|
+
code: int
|
|
78
|
+
message: str
|
|
79
|
+
# errors omitted as unused
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class GoogleResponseError(BaseModel):
|
|
83
|
+
model_config = camel_case_model_config
|
|
84
|
+
|
|
85
|
+
error: GoogleError
|
|
86
|
+
|
|
87
|
+
def to_exception(self) -> GoogleException:
|
|
88
|
+
return GoogleException(self.error.message)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class GoogleResponse[Result: BaseModel](RootModel[Result | GoogleResponseError]):
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class SignInResult(BaseModel):
|
|
96
|
+
"""
|
|
97
|
+
The result of signing in.
|
|
98
|
+
See https://docs.cloud.google.com/identity-platform/docs/reference/rest/v1/accounts/signInWithPassword
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
model_config = camel_case_model_config
|
|
102
|
+
|
|
103
|
+
email: str
|
|
104
|
+
"""The email of the authenticated user. Always present in the response."""
|
|
105
|
+
|
|
106
|
+
id_token: str
|
|
107
|
+
"""An Identity Platform ID token for the authenticated user."""
|
|
108
|
+
|
|
109
|
+
refresh_token: str
|
|
110
|
+
"""An Identity Platform refresh token for the authenticated user."""
|
|
111
|
+
|
|
112
|
+
expires_in: int
|
|
113
|
+
"""The number of seconds until the Identity Platform ID token expires."""
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class RefreshTokenResult(BaseModel):
|
|
117
|
+
"""
|
|
118
|
+
The result of refreshing a refresh_token.
|
|
119
|
+
See https://cloud.google.com/identity-platform/docs/use-rest-api#section-refresh-token
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
# NOT camelCase
|
|
123
|
+
|
|
124
|
+
expires_in: int
|
|
125
|
+
"""The number of seconds in which the ID token expires."""
|
|
126
|
+
|
|
127
|
+
token_type: str
|
|
128
|
+
"""The type of the refresh token, always "Bearer"."""
|
|
129
|
+
|
|
130
|
+
refresh_token: str
|
|
131
|
+
"""The Identity Platform refresh token provided in the request or a new refresh token."""
|
|
132
|
+
|
|
133
|
+
id_token: str
|
|
134
|
+
"""An Identity Platform ID token."""
|
|
135
|
+
|
|
136
|
+
user_id: str
|
|
137
|
+
"""The uid corresponding to the provided ID token."""
|
|
138
|
+
|
|
139
|
+
project_id: str
|
|
140
|
+
"""Your Google Cloud project ID."""
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def get_credentials(api_key: str, referer: str) -> Credentials:
|
|
144
|
+
credentials_path = get_credentials_path()
|
|
145
|
+
|
|
146
|
+
credentials = get_stored_credentials(credentials_path)
|
|
147
|
+
if credentials is not None:
|
|
148
|
+
try:
|
|
149
|
+
credentials.refresh_if_needed()
|
|
150
|
+
except GoogleException as e:
|
|
151
|
+
# `INVALID_ID_TOKEN` means the user must login again.
|
|
152
|
+
if e.message != "INVALID_ID_TOKEN":
|
|
153
|
+
raise e
|
|
154
|
+
|
|
155
|
+
return credentials
|
|
156
|
+
|
|
157
|
+
email = input("Email: ")
|
|
158
|
+
login_credentials: RawCredentials | None = None
|
|
159
|
+
|
|
160
|
+
while True:
|
|
161
|
+
try:
|
|
162
|
+
password = getpass.getpass("Password: ")
|
|
163
|
+
login_credentials = sign_in(api_key, email, password)
|
|
164
|
+
break
|
|
165
|
+
except GoogleException as e:
|
|
166
|
+
if e.message == "INVALID_PASSWORD":
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
raise e
|
|
170
|
+
|
|
171
|
+
return login_credentials.store(api_key, referer, credentials_path)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def get_credentials_path() -> Path:
|
|
175
|
+
home = Path.home()
|
|
176
|
+
return home.joinpath(".mph", "credentials.json")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def get_stored_credentials(credentials_path: Path) -> Credentials | None:
|
|
180
|
+
try:
|
|
181
|
+
with credentials_path.open() as f:
|
|
182
|
+
credentials_json = json.load(f)
|
|
183
|
+
stored_credentials = StoredCredentials.model_validate(credentials_json)
|
|
184
|
+
except FileNotFoundError:
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
credentials = Credentials(
|
|
188
|
+
email=stored_credentials.email,
|
|
189
|
+
id_token=stored_credentials.id_token,
|
|
190
|
+
refresh_token=stored_credentials.refresh_token,
|
|
191
|
+
expires_at=stored_credentials.expires_at,
|
|
192
|
+
api_key=stored_credentials.api_key,
|
|
193
|
+
referer=stored_credentials.referer,
|
|
194
|
+
credentials_path=credentials_path,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
return credentials
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def refresh_token(api_key: str, referer: str, refresh_token: str) -> RawCredentials:
|
|
201
|
+
response = requests.post(
|
|
202
|
+
"https://securetoken.googleapis.com/v1/token",
|
|
203
|
+
data={"grant_type": "refresh_token", "refresh_token": refresh_token},
|
|
204
|
+
params={
|
|
205
|
+
"key": api_key,
|
|
206
|
+
},
|
|
207
|
+
headers={
|
|
208
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
209
|
+
"Referer": referer,
|
|
210
|
+
},
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
result = (
|
|
214
|
+
GoogleResponse[RefreshTokenResult].model_validate_json(response.content).root
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
if isinstance(result, GoogleResponseError):
|
|
218
|
+
raise result.to_exception()
|
|
219
|
+
|
|
220
|
+
return RawCredentials(
|
|
221
|
+
email="",
|
|
222
|
+
id_token=result.id_token,
|
|
223
|
+
refresh_token=result.refresh_token,
|
|
224
|
+
expires_at=time.time() + result.expires_in,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def sign_in(api_key: str, email: str, password: str) -> RawCredentials:
|
|
229
|
+
response = requests.post(
|
|
230
|
+
"https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword",
|
|
231
|
+
json={
|
|
232
|
+
"email": email,
|
|
233
|
+
"password": password,
|
|
234
|
+
"returnSecureToken": "true",
|
|
235
|
+
},
|
|
236
|
+
params={
|
|
237
|
+
"key": api_key,
|
|
238
|
+
},
|
|
239
|
+
headers={"Referer": "http://myprice.health"},
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
result = GoogleResponse[SignInResult].model_validate_json(response.content).root
|
|
243
|
+
|
|
244
|
+
if isinstance(result, GoogleResponseError):
|
|
245
|
+
raise result.to_exception()
|
|
246
|
+
|
|
247
|
+
sign_in_result = result
|
|
248
|
+
|
|
249
|
+
credentials = RawCredentials(
|
|
250
|
+
email=sign_in_result.email,
|
|
251
|
+
id_token=sign_in_result.id_token,
|
|
252
|
+
refresh_token=sign_in_result.refresh_token,
|
|
253
|
+
expires_at=time.time() + sign_in_result.expires_in,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
return credentials
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
from abc import abstractmethod
|
|
2
|
+
from datetime import datetime as dt
|
|
3
|
+
from datetime import tzinfo
|
|
4
|
+
from typing import Any, ClassVar, Self, SupportsIndex
|
|
5
|
+
|
|
6
|
+
from pydantic import GetCoreSchemaHandler
|
|
7
|
+
from pydantic_core import CoreSchema, core_schema
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AbstractDateTime:
|
|
11
|
+
format: ClassVar[str]
|
|
12
|
+
secondary_formats: ClassVar[list[str]] = []
|
|
13
|
+
datetime: dt
|
|
14
|
+
|
|
15
|
+
@classmethod
|
|
16
|
+
@abstractmethod
|
|
17
|
+
def from_datetime(cls, datetime: dt) -> Self:
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
def __str__(self):
|
|
21
|
+
return self.datetime.strftime(self.format)
|
|
22
|
+
|
|
23
|
+
# Based off of this: https://docs.pydantic.dev/2.1/usage/types/custom/#handling-third-party-types
|
|
24
|
+
@classmethod
|
|
25
|
+
def __get_pydantic_core_schema__(
|
|
26
|
+
cls, source_type: Any, handler: GetCoreSchemaHandler
|
|
27
|
+
) -> CoreSchema:
|
|
28
|
+
def to_datetime_cls(value: str) -> Self:
|
|
29
|
+
datetime: dt | None = None
|
|
30
|
+
|
|
31
|
+
if len(cls.secondary_formats) == 0:
|
|
32
|
+
datetime = dt.strptime(value, cls.format)
|
|
33
|
+
else:
|
|
34
|
+
formats = [cls.format, *cls.secondary_formats]
|
|
35
|
+
for format in formats:
|
|
36
|
+
try:
|
|
37
|
+
datetime = dt.strptime(value, format)
|
|
38
|
+
except ValueError:
|
|
39
|
+
continue
|
|
40
|
+
|
|
41
|
+
if datetime is None:
|
|
42
|
+
raise ValueError(
|
|
43
|
+
f"Could not parse date {repr(value)} with format {repr(cls.format)} or {repr(cls.secondary_formats)}"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
return cls.from_datetime(datetime)
|
|
47
|
+
|
|
48
|
+
from_str = core_schema.chain_schema(
|
|
49
|
+
[
|
|
50
|
+
core_schema.str_schema(),
|
|
51
|
+
core_schema.no_info_plain_validator_function(to_datetime_cls),
|
|
52
|
+
]
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
return core_schema.json_or_python_schema(
|
|
56
|
+
json_schema=from_str,
|
|
57
|
+
python_schema=core_schema.union_schema(
|
|
58
|
+
[
|
|
59
|
+
core_schema.is_instance_schema(cls),
|
|
60
|
+
from_str,
|
|
61
|
+
]
|
|
62
|
+
),
|
|
63
|
+
serialization=core_schema.plain_serializer_function_ser_schema(
|
|
64
|
+
lambda date: str(date)
|
|
65
|
+
),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class Date(AbstractDateTime):
|
|
70
|
+
"""Date is a custom type for representing dates in the format YYYYMMDD"""
|
|
71
|
+
|
|
72
|
+
format = "%Y%m%d"
|
|
73
|
+
|
|
74
|
+
year: int
|
|
75
|
+
month: int
|
|
76
|
+
day: int
|
|
77
|
+
|
|
78
|
+
def __init__(self, year: int, month: int, day: int):
|
|
79
|
+
self.year = year
|
|
80
|
+
self.month = month
|
|
81
|
+
self.day = day
|
|
82
|
+
self.datetime = dt(year, month, day)
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def from_datetime(cls, datetime: dt):
|
|
86
|
+
return cls(datetime.year, datetime.month, datetime.day)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class DateTime(AbstractDateTime):
|
|
90
|
+
"""DateTime is a custom type for representing dates"""
|
|
91
|
+
|
|
92
|
+
# `%f` is padded to 6 characters but should be 9 overall.
|
|
93
|
+
format = "%Y-%m-%d %H:%M:%S.000%f%z"
|
|
94
|
+
|
|
95
|
+
# Same constructor as datetime has.
|
|
96
|
+
def __init__(
|
|
97
|
+
self,
|
|
98
|
+
year: SupportsIndex,
|
|
99
|
+
month: SupportsIndex,
|
|
100
|
+
day: SupportsIndex,
|
|
101
|
+
hour: SupportsIndex = 0,
|
|
102
|
+
minute: SupportsIndex = 0,
|
|
103
|
+
second: SupportsIndex = 0,
|
|
104
|
+
microsecond: SupportsIndex = 0,
|
|
105
|
+
tzinfo: tzinfo | None = None,
|
|
106
|
+
*,
|
|
107
|
+
fold: int = 0,
|
|
108
|
+
):
|
|
109
|
+
self.datetime = dt(
|
|
110
|
+
year, month, day, hour, minute, second, microsecond, tzinfo, fold=fold
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
@classmethod
|
|
114
|
+
def from_datetime(cls, datetime: dt):
|
|
115
|
+
return cls(
|
|
116
|
+
datetime.year,
|
|
117
|
+
datetime.month,
|
|
118
|
+
datetime.day,
|
|
119
|
+
datetime.hour,
|
|
120
|
+
datetime.minute,
|
|
121
|
+
datetime.second,
|
|
122
|
+
datetime.microsecond,
|
|
123
|
+
datetime.tzinfo,
|
|
124
|
+
fold=datetime.fold,
|
|
125
|
+
)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from dotenv import load_dotenv
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def load_env():
|
|
8
|
+
path = Path(__file__)
|
|
9
|
+
root_dir = None
|
|
10
|
+
env_file = None
|
|
11
|
+
for parent in path.parents:
|
|
12
|
+
root_dir = parent
|
|
13
|
+
env_file = root_dir.joinpath(".env")
|
|
14
|
+
if env_file.exists():
|
|
15
|
+
break
|
|
16
|
+
|
|
17
|
+
if root_dir is None:
|
|
18
|
+
print("No .env file found", file=sys.stderr)
|
|
19
|
+
return
|
|
20
|
+
|
|
21
|
+
print(f"Using .env from {repr(str(root_dir))}.")
|
|
22
|
+
|
|
23
|
+
load_dotenv(root_dir.joinpath(".env"))
|
|
24
|
+
load_dotenv(root_dir.joinpath(".env.local"))
|
|
@@ -4,6 +4,7 @@ from typing import Annotated, Optional
|
|
|
4
4
|
from pydantic import BaseModel, Field
|
|
5
5
|
|
|
6
6
|
from .client import PriceConfig
|
|
7
|
+
from .date import DateTime
|
|
7
8
|
from .fields import camel_case_model_config, field_name
|
|
8
9
|
from .response import ResponseError
|
|
9
10
|
|
|
@@ -73,6 +74,7 @@ class MedicareSource(str, Enum):
|
|
|
73
74
|
ManualPricing = "Manual Pricing"
|
|
74
75
|
SNF = "SNF PPS"
|
|
75
76
|
Synthetic = "Synthetic Medicare"
|
|
77
|
+
DMEFS = "DMEFS"
|
|
76
78
|
|
|
77
79
|
|
|
78
80
|
class InpatientPriceDetail(BaseModel):
|
|
@@ -420,3 +422,137 @@ class Pricing(BaseModel):
|
|
|
420
422
|
|
|
421
423
|
edit_error: Optional[ResponseError] = None
|
|
422
424
|
"""An error that occurred during some step of the pricing process"""
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
class Step(str, Enum):
|
|
428
|
+
new = "New"
|
|
429
|
+
received = "Received"
|
|
430
|
+
pending = "Pending"
|
|
431
|
+
held = "Held"
|
|
432
|
+
error = "Error"
|
|
433
|
+
input_validated = "Input Validated"
|
|
434
|
+
provider_matched = "Provider Matched"
|
|
435
|
+
edit_complete = "Edit Complete"
|
|
436
|
+
medicare_priced = "Medicare Priced"
|
|
437
|
+
primary_allowed_priced = "Primary Allowed Priced"
|
|
438
|
+
network_allowed_priced = "Network Allowed Priced"
|
|
439
|
+
out_of_network = "Out of Network"
|
|
440
|
+
request_more_info = "Request More Info"
|
|
441
|
+
priced = "Priced"
|
|
442
|
+
returned = "Returned"
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
class Status(str, Enum):
|
|
446
|
+
pending_claim_input_validation = "Claim Input Validation"
|
|
447
|
+
pending_claim_edit_review = "Claim Edit Review"
|
|
448
|
+
pending_provider_matching = "Provider Matching"
|
|
449
|
+
pending_medicare_review = "Medicare Review"
|
|
450
|
+
pending_medicare_calculation = "Medicare Calculation"
|
|
451
|
+
pending_primary_allowed_review = "Primary Allowed Review"
|
|
452
|
+
pending_network_allowed_review = "Network Allowed Review"
|
|
453
|
+
pending_primary_allowed_determination = "Primary Allowed Determination"
|
|
454
|
+
pending_network_allowed_determination = "Network Allowed Determination"
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
class StepAndStatus(BaseModel):
|
|
458
|
+
model_config = camel_case_model_config
|
|
459
|
+
|
|
460
|
+
step: Step
|
|
461
|
+
status: Optional[Status] = None
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
class ClaimStatus(StepAndStatus):
|
|
465
|
+
model_config = camel_case_model_config
|
|
466
|
+
|
|
467
|
+
updated_by: Optional[str] = None
|
|
468
|
+
updated_at: Optional[DateTime] = None
|
|
469
|
+
pricing: Optional[Pricing] = None
|
|
470
|
+
error: Optional[ResponseError] = None
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
status_new = StepAndStatus(step=Step.new)
|
|
474
|
+
"""created by TPA. We use the transaction date as a proxy for this date"""
|
|
475
|
+
|
|
476
|
+
status_received = StepAndStatus(step=Step.received)
|
|
477
|
+
"""received and ready for processing. This is modified date of the file we get from SFTP"""
|
|
478
|
+
|
|
479
|
+
status_held = StepAndStatus(step=Step.held)
|
|
480
|
+
"""held for various reasons"""
|
|
481
|
+
|
|
482
|
+
status_error = StepAndStatus(step=Step.error)
|
|
483
|
+
"""claim encountered an error during processing"""
|
|
484
|
+
|
|
485
|
+
status_input_validated = StepAndStatus(step=Step.input_validated)
|
|
486
|
+
"""claim input has been validated"""
|
|
487
|
+
|
|
488
|
+
status_provider_matched = StepAndStatus(step=Step.provider_matched)
|
|
489
|
+
"""providers in the claim have been matched to the provider system of record"""
|
|
490
|
+
|
|
491
|
+
status_edit_complete = StepAndStatus(step=Step.edit_complete)
|
|
492
|
+
"""claim has been edited and is ready for pricing"""
|
|
493
|
+
|
|
494
|
+
status_medicare_priced = StepAndStatus(step=Step.medicare_priced)
|
|
495
|
+
"""claim has been priced according to Medicare"""
|
|
496
|
+
|
|
497
|
+
status_primary_allowed_priced = StepAndStatus(step=Step.primary_allowed_priced)
|
|
498
|
+
"""claim has been priced according to the primary allowed amount (e.g. contract, RBP, etc.)"""
|
|
499
|
+
|
|
500
|
+
status_network_allowed_priced = StepAndStatus(step=Step.network_allowed_priced)
|
|
501
|
+
"""claim has been priced according to the allowed amount of the network"""
|
|
502
|
+
|
|
503
|
+
status_out_of_network = StepAndStatus(step=Step.out_of_network)
|
|
504
|
+
"""is out of network"""
|
|
505
|
+
|
|
506
|
+
status_request_more_info = StepAndStatus(step=Step.request_more_info)
|
|
507
|
+
"""return claim to trading partner for more information to enable correct processing"""
|
|
508
|
+
|
|
509
|
+
status_priced = StepAndStatus(step=Step.priced)
|
|
510
|
+
"""done pricing"""
|
|
511
|
+
|
|
512
|
+
status_returned = StepAndStatus(step=Step.returned)
|
|
513
|
+
"""returned to TPA"""
|
|
514
|
+
|
|
515
|
+
status_pending_claim_input_validation = StepAndStatus(
|
|
516
|
+
step=Step.pending, status=Status.pending_claim_input_validation
|
|
517
|
+
)
|
|
518
|
+
"""waiting for claim input validation"""
|
|
519
|
+
|
|
520
|
+
status_pending_claim_edit_review = StepAndStatus(
|
|
521
|
+
step=Step.pending, status=Status.pending_claim_edit_review
|
|
522
|
+
)
|
|
523
|
+
"""waiting for claim edit review"""
|
|
524
|
+
|
|
525
|
+
status_pending_provider_matching = StepAndStatus(
|
|
526
|
+
step=Step.pending, status=Status.pending_provider_matching
|
|
527
|
+
)
|
|
528
|
+
"""waiting for provider matching"""
|
|
529
|
+
|
|
530
|
+
status_pending_medicare_review = StepAndStatus(
|
|
531
|
+
step=Step.pending, status=Status.pending_medicare_review
|
|
532
|
+
)
|
|
533
|
+
"""waiting for Medicare amount review"""
|
|
534
|
+
|
|
535
|
+
status_pending_medicare_calculation = StepAndStatus(
|
|
536
|
+
step=Step.pending, status=Status.pending_medicare_calculation
|
|
537
|
+
)
|
|
538
|
+
"""waiting for Medicare amount calculation"""
|
|
539
|
+
|
|
540
|
+
status_pending_primary_allowed_review = StepAndStatus(
|
|
541
|
+
step=Step.pending, status=Status.pending_primary_allowed_review
|
|
542
|
+
)
|
|
543
|
+
"""waiting for primary allowed amount review"""
|
|
544
|
+
|
|
545
|
+
status_pending_network_allowed_review = StepAndStatus(
|
|
546
|
+
step=Step.pending, status=Status.pending_network_allowed_review
|
|
547
|
+
)
|
|
548
|
+
"""waiting for network allowed amount review"""
|
|
549
|
+
|
|
550
|
+
status_pending_primary_allowed_determination = StepAndStatus(
|
|
551
|
+
step=Step.pending, status=Status.pending_primary_allowed_determination
|
|
552
|
+
)
|
|
553
|
+
"""waiting for the primary allowed amount (e.g. contract, RBP rate, etc.) to be determined"""
|
|
554
|
+
|
|
555
|
+
status_pending_network_allowed_determination = StepAndStatus(
|
|
556
|
+
step=Step.pending, status=Status.pending_network_allowed_determination
|
|
557
|
+
)
|
|
558
|
+
"""waiting for allowed amount from the network"""
|
|
File without changes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "mphapi"
|
|
3
|
-
version = "1.
|
|
3
|
+
version = "1.12.1"
|
|
4
4
|
description = "A Python interface to the MyPriceHealth API"
|
|
5
5
|
authors = ["David Archibald <davidarchibald@myprice.health>"]
|
|
6
6
|
|
|
@@ -8,10 +8,16 @@ authors = ["David Archibald <davidarchibald@myprice.health>"]
|
|
|
8
8
|
python = "^3.12"
|
|
9
9
|
pydantic = "^2.6.4"
|
|
10
10
|
requests = "^2.31.0"
|
|
11
|
-
|
|
11
|
+
python-dotenv = "^1.1.1"
|
|
12
12
|
|
|
13
13
|
[tool.poetry.group.dev.dependencies]
|
|
14
|
+
pytest = "^8.4.1"
|
|
14
15
|
pytest-snapshot = "^0.9.0"
|
|
16
|
+
coverage = "^7.10.4"
|
|
17
|
+
black = "^25.1.0"
|
|
18
|
+
isort = "^6.0.1"
|
|
19
|
+
flake8 = "^7.3.0"
|
|
20
|
+
pyright = "^1.1.403"
|
|
15
21
|
|
|
16
22
|
[build-system]
|
|
17
23
|
requires = ["poetry-core"]
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
import json
|
|
2
|
-
import os
|
|
3
|
-
|
|
4
|
-
# This import annoys Pylance for some reason.
|
|
5
|
-
from pytest_snapshot.plugin import Snapshot # type: ignore
|
|
6
|
-
|
|
7
|
-
from . import Claim, Client, PriceConfig
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def test_client(snapshot: Snapshot):
|
|
11
|
-
api_key = os.getenv("API_KEY")
|
|
12
|
-
if api_key is None:
|
|
13
|
-
raise EnvironmentError("API_KEY must be set")
|
|
14
|
-
|
|
15
|
-
api_url = os.getenv("API_URL")
|
|
16
|
-
|
|
17
|
-
client = Client(api_key, url=api_url)
|
|
18
|
-
|
|
19
|
-
config = PriceConfig(
|
|
20
|
-
is_commercial=True,
|
|
21
|
-
disable_cost_based_reimbursement=False,
|
|
22
|
-
use_commercial_synthetic_for_not_allowed=True,
|
|
23
|
-
use_drg_from_grouper=False,
|
|
24
|
-
use_best_drg_price=True,
|
|
25
|
-
override_threshold=300,
|
|
26
|
-
include_edits=True,
|
|
27
|
-
)
|
|
28
|
-
|
|
29
|
-
tests = ["hcfa", "inpatient", "outpatient"]
|
|
30
|
-
|
|
31
|
-
for test in tests:
|
|
32
|
-
with open(f"testdata/{test}.json", "r") as f:
|
|
33
|
-
data = json.load(f)
|
|
34
|
-
|
|
35
|
-
claim = Claim.model_validate(data)
|
|
36
|
-
pricing = client.price(config, claim)
|
|
37
|
-
|
|
38
|
-
snapshot.assert_match(pricing.model_dump_json(indent=4), f"{test}.json")
|
mphapi-1.9.1/mphapi/date.py
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
from datetime import datetime
|
|
2
|
-
from typing import Any
|
|
3
|
-
|
|
4
|
-
from pydantic import GetCoreSchemaHandler
|
|
5
|
-
from pydantic_core import CoreSchema, core_schema
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
class Date:
|
|
9
|
-
"""Date is a custom type for representing dates in the format YYYYMMDD"""
|
|
10
|
-
|
|
11
|
-
year: int
|
|
12
|
-
month: int
|
|
13
|
-
day: int
|
|
14
|
-
|
|
15
|
-
def __init__(self, year: int, month: int, day: int):
|
|
16
|
-
self.year = year
|
|
17
|
-
self.month = month
|
|
18
|
-
self.day = day
|
|
19
|
-
|
|
20
|
-
def __str__(self):
|
|
21
|
-
return f"{self.year}{self.month:02d}{self.day:02d}"
|
|
22
|
-
|
|
23
|
-
# Based off of this: https://docs.pydantic.dev/2.1/usage/types/custom/#handling-third-party-types
|
|
24
|
-
@classmethod
|
|
25
|
-
def __get_pydantic_core_schema__(
|
|
26
|
-
cls, source_type: Any, handler: GetCoreSchemaHandler
|
|
27
|
-
) -> CoreSchema:
|
|
28
|
-
def to_date(value: str) -> Date:
|
|
29
|
-
time = datetime.strptime(value, "%Y%m%d")
|
|
30
|
-
|
|
31
|
-
return Date(time.year, time.month, time.day)
|
|
32
|
-
|
|
33
|
-
from_str = core_schema.chain_schema(
|
|
34
|
-
[
|
|
35
|
-
core_schema.str_schema(),
|
|
36
|
-
core_schema.no_info_plain_validator_function(to_date),
|
|
37
|
-
]
|
|
38
|
-
)
|
|
39
|
-
|
|
40
|
-
return core_schema.json_or_python_schema(
|
|
41
|
-
json_schema=from_str,
|
|
42
|
-
python_schema=core_schema.union_schema(
|
|
43
|
-
[
|
|
44
|
-
core_schema.is_instance_schema(Date),
|
|
45
|
-
from_str,
|
|
46
|
-
]
|
|
47
|
-
),
|
|
48
|
-
serialization=core_schema.plain_serializer_function_ser_schema(
|
|
49
|
-
lambda date: str(date)
|
|
50
|
-
),
|
|
51
|
-
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|