pmhclib 0.9.0__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.
pmhclib-0.9.0/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 David Wales, Jonathan Stucken, South Western Sydney Primary
4
+ Health Network
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
7
+ this software and associated documentation files (the "Software"), to deal in
8
+ the Software without restriction, including without limitation the rights to
9
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10
+ of the Software, and to permit persons to whom the Software is furnished to do
11
+ so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
pmhclib-0.9.0/PKG-INFO ADDED
@@ -0,0 +1,151 @@
1
+ Metadata-Version: 2.4
2
+ Name: pmhclib
3
+ Version: 0.9.0
4
+ Summary: Python wrapper for unofficial PMHC MDS portal API
5
+ License-File: LICENSE
6
+ Author: David Wales
7
+ Author-email: david.wales@swsphn.com.au
8
+ Requires-Python: >=3.10
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Requires-Dist: beautifulsoup4 (>=4.12.3,<5.0.0)
16
+ Requires-Dist: pyotp (>=2.9.0,<3.0.0)
17
+ Requires-Dist: requests (>=2.32.3,<3.0.0)
18
+ Requires-Dist: rich (>=13.9.4,<14.0.0)
19
+ Description-Content-Type: text/markdown
20
+
21
+ # pmhclib
22
+
23
+ pmhclib is a Python wrapper for the unofficial PMHC MDS portal API. You
24
+ can use it to automate uploads and downloads to the PMHC portal.
25
+
26
+ ## Install
27
+
28
+ `pmhclib` is a Python package. You should be able to install
29
+ it directly with `pip` or `poetry`:
30
+
31
+ ``` sh
32
+ pip install pmhclib@git+https://github.com/swsphn/pmhclib.git
33
+ ```
34
+
35
+ OR
36
+
37
+ ``` sh
38
+ poetry add pmhclib@git+https://github.com/swsphn/pmhclib.git
39
+ ```
40
+
41
+ This will install `pmhclib` as an importable Python library.
42
+
43
+
44
+ ## Usage
45
+
46
+ `pmhclib.PMHC` is intended to be used with a context manager. This
47
+ ensures that the requests session which performs the login process and the
48
+ API requests is correctly closed when the script exits. The standard
49
+ use pattern is as follows:
50
+
51
+ ``` python
52
+ from pmhclib import PMHC
53
+ with PMHC('PHN105') as pmhc:
54
+ pmhc.login()
55
+ ...
56
+ # other pmhc methods.
57
+ ```
58
+
59
+ `pmhc.login()` will read credentials from the following environment
60
+ variables if they are set:
61
+
62
+ ```
63
+ PMHC_USERNAME
64
+ PMHC_PASSWORD
65
+ PMHC_TOTP_SECRET
66
+ ```
67
+
68
+ Otherwise, you will be prompted for credentials interactively.
69
+
70
+ In PowerShell, you can set the environment variables interactively as
71
+ follows:
72
+
73
+ ``` ps1
74
+ $env:PMHC_USERNAME='your_username_here'
75
+ $env:PMHC_PASSWORD=python -c 'import getpass; print(getpass.getpass())'
76
+ $env:PMHC_TOTP_SECRET=python -c 'import getpass; print(getpass.getpass("TOTP Secret: "))'
77
+ ```
78
+
79
+ In a Unix shell (Mac, Linux), you can do:
80
+
81
+ ``` bash
82
+ export PMHC_USERNAME='your_username_here'
83
+ read -rs PMHC_PASSWORD && export PMHC_PASSWORD
84
+ read -rs PMHC_TOTP_SECRET && export PMHC_TOTP_SECRET
85
+ ```
86
+
87
+ NOTE: `PMHC_TOTP_SECRET` is the unchanging base32-encoded TOTP secret,
88
+ not the time-based six-digit code. You can likely find this secret in
89
+ the 'advanced' section of your TOTP app. It will be a long string of
90
+ upper-case letters and digits. See below for a list of TOTP apps which
91
+ support viewing the TOTP secret. It is also possible to get the secret
92
+ by scanning the setup QR code with a generic QR code reader, or by
93
+ clicking the _Trouble scanning_ button on the website to manually
94
+ configure the TOTP app:
95
+
96
+ ![manually view totp secret](./images/view-totp-secret-at-setup.png)
97
+
98
+ The six-digit code will be automatically calculated based on the current
99
+ time as required if `PMHC_TOTP_SECRET` is specified. Otherwise, the user
100
+ will be prompted to enter the current six-digit code.
101
+
102
+ Not all TOTP apps support viewing the secret. The following are known
103
+ to support this:
104
+
105
+ - [Aegis Authenticator](https://getaegis.app/) (Android only)
106
+ - [Bitwarden
107
+ Authenticator](https://bitwarden.com/products/authenticator/)
108
+ - [Ente Auth](https://github.com/ente-io/ente/tree/main/auth#readme)
109
+ - [2FA Authenticator (2FAS)](https://2fas.com/)
110
+
111
+ For more details, see the [list of recommended authenticator
112
+ apps][mfa-apps] on our Data Wiki.
113
+
114
+ ## Documentation
115
+
116
+ See the [online documentation][docs].
117
+
118
+ ### Built-in docs
119
+
120
+ Review the built-in documentation from Python as follows:
121
+
122
+ ``` python
123
+ >>> from pmhclib import PMHC
124
+ >>> help(PMHC)
125
+ ```
126
+
127
+ ### Build docs
128
+
129
+ You can also generate html documentation locally using [Sphinx][] if you
130
+ have a local copy of the repository.
131
+
132
+ Linux:
133
+
134
+ ```
135
+ cd docs
136
+ make html
137
+ ```
138
+
139
+ PowerShell:
140
+
141
+ ```
142
+ cd docs
143
+ ./make.bat html
144
+ ```
145
+
146
+ The generated documentation can be viewed at `docs/_build/html/index.html`.
147
+
148
+ [Sphinx]: https://www.sphinx-doc.org/
149
+ [docs]: https://swsphn.github.io/pmhclib/
150
+ [mfa-apps]: https://datawiki.swsphn.com.au/software/gui-tools/multi-factor-authentication-apps/
151
+
@@ -0,0 +1,130 @@
1
+ # pmhclib
2
+
3
+ pmhclib is a Python wrapper for the unofficial PMHC MDS portal API. You
4
+ can use it to automate uploads and downloads to the PMHC portal.
5
+
6
+ ## Install
7
+
8
+ `pmhclib` is a Python package. You should be able to install
9
+ it directly with `pip` or `poetry`:
10
+
11
+ ``` sh
12
+ pip install pmhclib@git+https://github.com/swsphn/pmhclib.git
13
+ ```
14
+
15
+ OR
16
+
17
+ ``` sh
18
+ poetry add pmhclib@git+https://github.com/swsphn/pmhclib.git
19
+ ```
20
+
21
+ This will install `pmhclib` as an importable Python library.
22
+
23
+
24
+ ## Usage
25
+
26
+ `pmhclib.PMHC` is intended to be used with a context manager. This
27
+ ensures that the requests session which performs the login process and the
28
+ API requests is correctly closed when the script exits. The standard
29
+ use pattern is as follows:
30
+
31
+ ``` python
32
+ from pmhclib import PMHC
33
+ with PMHC('PHN105') as pmhc:
34
+ pmhc.login()
35
+ ...
36
+ # other pmhc methods.
37
+ ```
38
+
39
+ `pmhc.login()` will read credentials from the following environment
40
+ variables if they are set:
41
+
42
+ ```
43
+ PMHC_USERNAME
44
+ PMHC_PASSWORD
45
+ PMHC_TOTP_SECRET
46
+ ```
47
+
48
+ Otherwise, you will be prompted for credentials interactively.
49
+
50
+ In PowerShell, you can set the environment variables interactively as
51
+ follows:
52
+
53
+ ``` ps1
54
+ $env:PMHC_USERNAME='your_username_here'
55
+ $env:PMHC_PASSWORD=python -c 'import getpass; print(getpass.getpass())'
56
+ $env:PMHC_TOTP_SECRET=python -c 'import getpass; print(getpass.getpass("TOTP Secret: "))'
57
+ ```
58
+
59
+ In a Unix shell (Mac, Linux), you can do:
60
+
61
+ ``` bash
62
+ export PMHC_USERNAME='your_username_here'
63
+ read -rs PMHC_PASSWORD && export PMHC_PASSWORD
64
+ read -rs PMHC_TOTP_SECRET && export PMHC_TOTP_SECRET
65
+ ```
66
+
67
+ NOTE: `PMHC_TOTP_SECRET` is the unchanging base32-encoded TOTP secret,
68
+ not the time-based six-digit code. You can likely find this secret in
69
+ the 'advanced' section of your TOTP app. It will be a long string of
70
+ upper-case letters and digits. See below for a list of TOTP apps which
71
+ support viewing the TOTP secret. It is also possible to get the secret
72
+ by scanning the setup QR code with a generic QR code reader, or by
73
+ clicking the _Trouble scanning_ button on the website to manually
74
+ configure the TOTP app:
75
+
76
+ ![manually view totp secret](./images/view-totp-secret-at-setup.png)
77
+
78
+ The six-digit code will be automatically calculated based on the current
79
+ time as required if `PMHC_TOTP_SECRET` is specified. Otherwise, the user
80
+ will be prompted to enter the current six-digit code.
81
+
82
+ Not all TOTP apps support viewing the secret. The following are known
83
+ to support this:
84
+
85
+ - [Aegis Authenticator](https://getaegis.app/) (Android only)
86
+ - [Bitwarden
87
+ Authenticator](https://bitwarden.com/products/authenticator/)
88
+ - [Ente Auth](https://github.com/ente-io/ente/tree/main/auth#readme)
89
+ - [2FA Authenticator (2FAS)](https://2fas.com/)
90
+
91
+ For more details, see the [list of recommended authenticator
92
+ apps][mfa-apps] on our Data Wiki.
93
+
94
+ ## Documentation
95
+
96
+ See the [online documentation][docs].
97
+
98
+ ### Built-in docs
99
+
100
+ Review the built-in documentation from Python as follows:
101
+
102
+ ``` python
103
+ >>> from pmhclib import PMHC
104
+ >>> help(PMHC)
105
+ ```
106
+
107
+ ### Build docs
108
+
109
+ You can also generate html documentation locally using [Sphinx][] if you
110
+ have a local copy of the repository.
111
+
112
+ Linux:
113
+
114
+ ```
115
+ cd docs
116
+ make html
117
+ ```
118
+
119
+ PowerShell:
120
+
121
+ ```
122
+ cd docs
123
+ ./make.bat html
124
+ ```
125
+
126
+ The generated documentation can be viewed at `docs/_build/html/index.html`.
127
+
128
+ [Sphinx]: https://www.sphinx-doc.org/
129
+ [docs]: https://swsphn.github.io/pmhclib/
130
+ [mfa-apps]: https://datawiki.swsphn.com.au/software/gui-tools/multi-factor-authentication-apps/
@@ -0,0 +1,27 @@
1
+ [tool.poetry]
2
+ name = "pmhclib"
3
+ version = "0.9.0"
4
+ description = "Python wrapper for unofficial PMHC MDS portal API"
5
+ authors = [
6
+ "David Wales <david.wales@swsphn.com.au>",
7
+ "Jonathan Stucken <jonathan.stucken@swsphn.com.au>"
8
+ ]
9
+ readme = "README.md"
10
+
11
+ [tool.poetry.dependencies]
12
+ python = ">=3.10"
13
+ rich = "^13.9.4"
14
+ pyotp = "^2.9.0"
15
+ requests = "^2.32.3"
16
+ beautifulsoup4 = "^4.12.3"
17
+
18
+ [tool.poetry.group.docs.dependencies]
19
+ sphinx = "^7.0.0"
20
+ myst-parser = "^2.0.0"
21
+ sphinx-autoapi = "^3.0.0"
22
+ sphinx-rtd-theme = "^2.0.0"
23
+ typing-extensions = "^4.12.2"
24
+
25
+ [build-system]
26
+ requires = ["poetry-core"]
27
+ build-backend = "poetry.core.masonry.api"
@@ -0,0 +1,10 @@
1
+ # read version from installed package
2
+ from importlib.metadata import version
3
+ __version__ = version("pmhclib")
4
+
5
+ # Import classes to be available at the top level.
6
+ # Allows importing with:
7
+ # from pmhclib import PMHC
8
+ # rather than
9
+ # from pmhclib.pmhc import PMHC
10
+ from .pmhc import PMHC, PMHCSpecification
@@ -0,0 +1,675 @@
1
+ """
2
+ This class provides a wrapper around the unofficial PMHC internal API.
3
+ It is useful for automating uploads and downloads from the PMHC portal.
4
+
5
+ No login details are saved anywhere
6
+ To speed up usage when doing repeated calls, create the following local env variables:
7
+ PMHC_USERNAME
8
+ PMHC_PASSWORD
9
+ PMHC_TOTP_SECRET
10
+
11
+ Note: See PMHC.login() documentation for details about `PMHC_TOTP_SECRET`.
12
+ """
13
+
14
+ import functools
15
+ import logging
16
+ import mimetypes
17
+ import os
18
+ import time
19
+ from dataclasses import dataclass
20
+ from datetime import date, timedelta
21
+ from enum import Enum, unique
22
+ from getpass import getpass
23
+ from pathlib import Path
24
+ from typing import Optional
25
+ from urllib.parse import urlsplit, parse_qs
26
+
27
+ import pyotp
28
+ import requests
29
+ from bs4 import BeautifulSoup
30
+ from rich.progress import Progress, TimeElapsedColumn
31
+
32
+
33
+ class FileNotFoundException(Exception):
34
+ """Custom error handler for when no file is found"""
35
+
36
+
37
+ class IncorrectFileType(Exception):
38
+ """Custom error handler for when an incorrect file is provided"""
39
+
40
+
41
+ class InvalidPmhcUser(Exception):
42
+ """Custom error handler for when a PMHC login is unsuccessful"""
43
+
44
+
45
+ class CouldNotFindPmhcUpload(Exception):
46
+ """Custom error handler for when an upload cannot be found on PMHC"""
47
+
48
+
49
+ class PmhcServerError(Exception):
50
+ """Custom exception for when a PMHC Server error is encountered"""
51
+
52
+
53
+ class MaxRetriesExceeded(Exception):
54
+ """Custom exception for when the maximum retries is exceeded"""
55
+
56
+
57
+ class SecureString(str):
58
+ """Show `'***'` instead of string value in tracebacks"""
59
+
60
+ def __repr__(self):
61
+ return "***"
62
+
63
+
64
+ @dataclass
65
+ class PMHCSpecificationRepresentation:
66
+ """Dataclass which provides structure for PMHCSpecification Enum."""
67
+
68
+ term: str
69
+ filter_term: str
70
+
71
+
72
+ @unique
73
+ class PMHCSpecification(PMHCSpecificationRepresentation, Enum):
74
+ """Enum of valid PMHC specifications"""
75
+
76
+ ALL = "meta", "Include data from all specifications"
77
+ HEADSPACE = "headspace", "headspace 4.1"
78
+ PMHC = "pmhc", "PMHC 5.0"
79
+ SURVEY = "survey", "SURVEY 1.0"
80
+
81
+
82
+ class PMHC:
83
+ """This class wraps the unofficial PMHC API.
84
+
85
+ Use it to automate tasks such as uploading to the PMHC website,
86
+ downloading error reports, downloading PMHC extracts, etc.
87
+
88
+ Usage:
89
+
90
+ This class is intended to be used with a context manager. This ensures
91
+ that the requests session is correctly closed. For
92
+ example:
93
+
94
+ >>> with PMHC('PHN105') as pmhc:
95
+ ... pmhc.login()
96
+ ... pmhc.download_error_json('94edf5e3-36b1-46d3-9178-bf3b142da6a1')
97
+
98
+ Args:
99
+ organisation_path: Your organisation's PMHC organisation_path
100
+ """
101
+
102
+ default_timeout = 60 # seconds
103
+
104
+ def __enter__(self):
105
+ """Initialise requests session. (Called automatically by context
106
+ manager.)
107
+ """
108
+ s = requests.Session()
109
+ # Ensure requests eventually timeout
110
+ # https://github.com/psf/requests/issues/2011#issuecomment-490050252
111
+ s.request = functools.partial(s.request, timeout=self.default_timeout)
112
+ self.s = s
113
+ return self # Return the instance of this class
114
+
115
+ def __exit__(self, exc_type, exc_value, traceback):
116
+ """Close requests session. (Called automatically when context
117
+ manager exits.)
118
+ """
119
+ # exc_type, exc_value, and traceback are required parameters in __exit__()
120
+ self.s.close()
121
+
122
+ def __init__(self, organisation_path: str):
123
+ # user_info is set by login()
124
+ self.user_info = None
125
+ self.organisation_path = organisation_path
126
+
127
+ def login(
128
+ self,
129
+ username: str | None = None,
130
+ password: str | None = None,
131
+ totp_secret: str | None = None,
132
+ ):
133
+ """Logs in to PMHC website. This allows us to reuse the login the session
134
+ across other class methods.
135
+
136
+ Set the following environment variables to skip interactive login prompt:
137
+
138
+ - `PMHC_USERNAME`
139
+ - `PMHC_PASSWORD`
140
+ - `PMHC_TOTP_SECRET`
141
+
142
+ NOTE: `PMHC_TOTP_SECRET` is _not_ the 6 digit time-dependent TOTP code,
143
+ but rather the long base32 encoded random secret. You might find this in
144
+ the 'advanced' section when editing the record in your TOTP app. It will
145
+ likely be a long string containing uppercase letters and numbers. This
146
+ will be automatically combined with the current time to derive the
147
+ correct 6 digit code.
148
+
149
+ Args:
150
+ username: PMHC username
151
+ password: PMHC password
152
+ totp_secret: static base32 encoded random totp secret Used to
153
+ generate the 6-digit time-based totp code. See note above.
154
+ """
155
+
156
+ pmhc_auth_url = "https://pmhc-mds.net/api/auth/login"
157
+ pmhc_login_url = "https://pmhc-mds.net/api/current-user"
158
+
159
+ # Prompt user for credentials if not set in env.
160
+ username = username or os.getenv("PMHC_USERNAME")
161
+ password = SecureString(password or os.getenv("PMHC_PASSWORD") or "")
162
+ totp_secret = SecureString(totp_secret or os.getenv("PMHC_TOTP_SECRET") or "")
163
+
164
+ while not username:
165
+ username = input("Enter PMHC username: ")
166
+
167
+ while not password:
168
+ password = SecureString(
169
+ getpass("Enter PMHC password (keyboard input will be hidden): ")
170
+ )
171
+
172
+ def extract_state(url: str) -> str:
173
+ """Extract state parameter from url query"""
174
+ return parse_qs(urlsplit(url).query)["state"][0]
175
+
176
+ initial_request = self.s.get(pmhc_auth_url)
177
+
178
+ # After redirects, the URL needed for the next POST is initial_request.url
179
+ identifier_url = initial_request.url
180
+ identifier_request = self.s.post(
181
+ identifier_url,
182
+ data={
183
+ "state": extract_state(identifier_url),
184
+ "username": username,
185
+ },
186
+ )
187
+
188
+ password_url = identifier_request.url
189
+ password_request = self.s.post(
190
+ password_url,
191
+ data={
192
+ "state": extract_state(password_url),
193
+ "username": username,
194
+ "password": password,
195
+ },
196
+ )
197
+
198
+ mfa_detect_url = password_request.url
199
+ mfa_detect_request = self.s.post(
200
+ mfa_detect_url,
201
+ data={
202
+ "state": extract_state(mfa_detect_url),
203
+ "action": "default",
204
+ "js-available": "false",
205
+ "webauthn-available": "true",
206
+ " is-brave": "false",
207
+ "webauthn-platform-available": "false",
208
+ },
209
+ )
210
+
211
+ mfa_url = mfa_detect_request.url
212
+
213
+ # Detect invalid username or password error
214
+ # If the username and password are correct, we should be
215
+ # redirected to /u/mfa-otp-challenge.
216
+ # If we are still on /u/login/password, then the username and
217
+ # password are probably invalid.
218
+ if mfa_url.startswith("https://login.logicly.com.au/u/login/password"):
219
+ logging.debug("Got password URL instead of expected MFA URL:")
220
+ logging.debug(f"{mfa_url}")
221
+ error_soup = BeautifulSoup(password_request.text, "html.parser")
222
+ error_message = error_soup.select_one(
223
+ 'span[id="error-element-password"]'
224
+ ).get_text()
225
+ raise InvalidPmhcUser(
226
+ f"Did not reach expected OTP page. Error message:\n{error_message}"
227
+ )
228
+
229
+ # Note: We get the code _after_ loading the page and entering
230
+ # the username and password, to ensure that it is still valid
231
+ # when we submit it.
232
+ logging.info("Entering TOTP MFA code")
233
+ if totp_secret:
234
+ totp = pyotp.TOTP(totp_secret)
235
+ totp_code = SecureString(totp.now())
236
+ else:
237
+ totp_code = None
238
+ while not totp_code:
239
+ totp_code = SecureString(
240
+ getpass(
241
+ "Enter six-digit MFA code (keyboard input will be hidden): "
242
+ )
243
+ )
244
+
245
+ mfa_request = self.s.post(
246
+ mfa_url,
247
+ data={
248
+ "state": extract_state(mfa_url),
249
+ "code": totp_code,
250
+ },
251
+ )
252
+
253
+ # confirm login was successful
254
+ user_query = self.s.get("https://pmhc-mds.net/api/current-user")
255
+ self.user_info = user_query.json()
256
+
257
+ # error key will be present if login was unsuccessful
258
+ if "error" in self.user_info:
259
+ raise InvalidPmhcUser(
260
+ "PMHC login was unsuccessful. Are you sure you entered "
261
+ "correct credentials?"
262
+ )
263
+
264
+ def upload_file(
265
+ self,
266
+ input_file: Path,
267
+ test: bool = True,
268
+ ) -> Path:
269
+ """Uploads a user specified file to PMHC website.
270
+
271
+ Args:
272
+ input_file: Path to the file e.g.
273
+ `'PMHC_MDS_20230101_20230131.xlsx'`
274
+ test: Upload in 'test' or 'live' mode? Defaults to `True`
275
+ ('test'). Use `False` ('live') with care!
276
+
277
+ Raises:
278
+ IncorrectFileType: If user uploads a bad filetype
279
+ FileNotFoundException: If we cannot find user file
280
+
281
+ Returns:
282
+ Filename of the new file we generated for matching purposes
283
+ """
284
+
285
+ # check file looks ok
286
+ if input_file.suffix not in (".xlsx", ".zip"):
287
+ raise IncorrectFileType(
288
+ "Only .xlsx or .zip (containing multiple csv's) are acceptable PMHC "
289
+ "input files"
290
+ )
291
+
292
+ if not input_file.exists():
293
+ raise FileNotFoundException(
294
+ "Input file does not exist - please check the file path and try again"
295
+ )
296
+
297
+ # check no uploads are currently being processed
298
+ # PMHC only allows one upload at a time per user account.
299
+ # This usually only occurs if the user is also using their browser to upload
300
+ # manually at the same time as running this script
301
+ self.wait_for_upload()
302
+
303
+ mode = "test" if test else "live"
304
+ print(
305
+ f"Uploading '{input_file}' to PMHC as a '{mode}' file\n"
306
+ "It usually takes approx 3-10 minutes for PMHC to process xlsx files "
307
+ "depending on the number of months included in the data, less for zipped "
308
+ "csv files"
309
+ )
310
+
311
+ # First PUT the file and receive a uuid
312
+ with open(input_file, "rb") as file:
313
+ upload_response = self.s.put(
314
+ "https://uploader.strategicdata.com.au/upload",
315
+ files={
316
+ "file": (
317
+ input_file.name, # file name
318
+ file, # file object
319
+ mimetypes.guess_type(input_file)[0], # content type
320
+ )
321
+ },
322
+ )
323
+
324
+ upload_status = upload_response.json()
325
+ logging.debug("Upload status:")
326
+ logging.debug(upload_status)
327
+
328
+ uuid = upload_status["id"]
329
+
330
+ # Second POST the upload details
331
+ # This is required to register the upload with the PMHC portal
332
+ post_response = self.s.post(
333
+ f"https://pmhc-mds.net/api/organisations/{self.organisation_path}/uploads",
334
+ json={
335
+ "uuid": uuid,
336
+ "filename": input_file.name,
337
+ "test": test,
338
+ "encoded_organisation_path": self.organisation_path,
339
+ },
340
+ )
341
+ logging.info("Upload details POST response:")
342
+ logging.info(post_response)
343
+ logging.info(post_response.text)
344
+
345
+ return uuid
346
+
347
+ def wait_for_upload(self):
348
+ """Waits for a PMHC upload to complete processing in 'test' mode"""
349
+
350
+ # check to see if the PMHC upload queue is free
351
+ delay = 10
352
+ with Progress(*Progress.get_default_columns(), TimeElapsedColumn()) as progress:
353
+ processing_task = progress.add_task(
354
+ "Checking PMHC upload queue...", total=None
355
+ )
356
+ while self.is_upload_processing():
357
+ progress.update(
358
+ processing_task, description="Waiting for PMHC processing..."
359
+ )
360
+ time.sleep(delay)
361
+
362
+ def download_error_json(self, uuid: str, download_folder: Path = Path(".")) -> Path:
363
+ """Downloads a JSON error file from PMHC
364
+ This is useful for matching against uploaded files and processing
365
+
366
+ Args:
367
+ uuid: PMHC upload uuid from View Uploads page. For
368
+ example: `'94edf5e3-36b1-46d3-9178-bf3b142da6a1'`.
369
+ The uuid is found in the URL to the upload summary page.
370
+ download_folder: Location to save the downloaded error
371
+ JSON.
372
+
373
+ Returns:
374
+ Path to JSON file saved to local disk
375
+ """
376
+
377
+ url = f"https://pmhc-mds.net/api/organisations/{self.organisation_path}/uploads/{uuid}"
378
+ upload_errors_json = self.s.get(url)
379
+
380
+ download_folder.mkdir(parents=True, exist_ok=True)
381
+ filename = download_folder / f"{uuid}.json"
382
+ with open(filename, "wb") as file:
383
+ file.write(upload_errors_json.content)
384
+
385
+ logging.info(f"Saved JSON file to disk: '{filename}'")
386
+
387
+ return filename
388
+
389
+ def is_upload_processing(self) -> bool:
390
+ """Checks if the user has an upload currently processing in either live or
391
+ test mode. Useful for checking before we do certain actions e.g. try upload
392
+ another file, because this script can only handle one 'processing' file at a time
393
+
394
+ Returns:
395
+ `True` if an upload is currently processing, otherwise `False`.
396
+ """
397
+ # Get a list of all this user's uploads ('processing', 'complete'
398
+ # and 'error' status)
399
+ # The filter parameter only accepts 'name', not 'username' or 'email'
400
+ # This is not ideal, as if there is another user with the same name,
401
+ # uploading at the same time, then you will be blocked from uploading
402
+ # until their upload completes. But this seems to be the best we can
403
+ # do within the limits of the unofficial PMHC Portal API.
404
+ pmhc_name = self.user_info["name"]
405
+ json_list = self.s.get(
406
+ f"https://pmhc-mds.net/api/uploads?name={pmhc_name}&sort=-date",
407
+ headers={"Range": "0-19"},
408
+ ).json()
409
+ # see if any are in a 'processing' state
410
+ for json in json_list:
411
+ if "status" in json and json["status"] == "processing":
412
+ return True
413
+
414
+ # all ok, none are processing, we are free to now upload a new file
415
+ return False
416
+
417
+ def wait_for_extract(self, uuid: str, max_retries: int = 20) -> bool:
418
+ """Wait for an extract with given uuid to have status
419
+ 'Completed'.
420
+
421
+ Both PMHC server errors and incomplete processing extracts
422
+ return the same HTTP status (400) and JSON response when
423
+ trying to fetch the extract by UUID:
424
+
425
+ .. code-block:: text
426
+
427
+ https://pmhc-mds.net/api/extract/{download_uuid}/fetch
428
+
429
+ {
430
+ "errors": {
431
+ "export_fetch": "Can not fetch extract for uuid
432
+ [123...]. Extract is not complete. Extract has
433
+ expired."
434
+ }
435
+ }
436
+
437
+ For this reason, it's not sufficient to simply try the
438
+ download URL until we get a success code. If there is
439
+ a PMHC server error, we will end up retrying forever.
440
+
441
+ Instead, we need to fetch the list of extracts and filter
442
+ for one with the required uuid. We can then check the
443
+ extract status explicitly, which should be one of the
444
+ following values:
445
+
446
+ - Completed
447
+ - Processing
448
+ - Queued
449
+ - Error
450
+
451
+ If Completed, we can download the extract.
452
+ If Processing or Queued, keep looping and waiting.
453
+ If Error, the extract has failed. Exit.
454
+ """
455
+ retries = 0
456
+ while retries < max_retries:
457
+ logging.info(f"wait_for_extract: attempt: {retries}")
458
+ time.sleep(30)
459
+ try:
460
+ extracts_request = self.s.get(
461
+ "https://pmhc-mds.net/api/extract?sort=-date"
462
+ )
463
+
464
+ extracts = extracts_request.json()
465
+ extract = next(filter(lambda item: item.get("uuid") == uuid, extracts))
466
+ status = extract["status"]
467
+
468
+ if status == "Completed":
469
+ return True
470
+ if status == "Error":
471
+ logging.error(f"PMHC extract with uuid {uuid} has failed.")
472
+ logging.error("See PMHC Server error:")
473
+ logging.error(extract["stash"]["error"])
474
+ raise PmhcServerError("The PMHC extract has failed on the server.")
475
+
476
+ except (requests.ReadTimeout, requests.ConnectionError) as err:
477
+ retries += 1
478
+ logging.warning(err)
479
+ logging.warning(
480
+ f"Request timed out ({retries} of {max_retries}). Retrying."
481
+ )
482
+
483
+ else:
484
+ raise MaxRetriesExceeded(
485
+ f"Tried fetching PMHC extract list {retries} times"
486
+ )
487
+
488
+ def download_extract_request(
489
+ self,
490
+ start_date: date = date.today() - timedelta(days=30),
491
+ end_date: date = date.today(),
492
+ organisation_path: Optional[str] = None,
493
+ specification: PMHCSpecification = PMHCSpecification.PMHC,
494
+ without_associated_dates: bool = False,
495
+ matched_episodes: bool = False,
496
+ max_retries: int = 20,
497
+ **kwargs,
498
+ ) -> Path:
499
+ """Extract PMHC MDS Data within the date range. If no date range
500
+ is given, `start_date` defaults to 30 days before the current
501
+ date and `end_date` defaults to the current date.
502
+
503
+ Returns a requests.Response object for the generated data
504
+ extract. This enables you to use any of the supported
505
+ requests.Response methods, rather than simply downloading to a
506
+ local file. (If you want to just download to a local file, use
507
+ the download_pmhc_mds() method.)
508
+
509
+ Args:
510
+ start_date: start date for extract
511
+ end_date: end date for extract (default: today)
512
+ organisation_path: Organisation path for downloaded extract.
513
+ Defaults to your organisation as specified when
514
+ initialising `pmhclib.PMHC`. However, can be a different
515
+ organisation, for example if you are a PHN, but only
516
+ want to download data for a single provider
517
+ organisation.
518
+ specification: Specification for extract. (default:
519
+ `PMHCSpecification.PMHC`, which returns data from the
520
+ current PMHC specification.)
521
+ without_associated_dates: Enable extract option
522
+ "Include data without associated dates"
523
+ matched_episodes: Enable extract option
524
+ "Include all data associated with matched episodes"
525
+ max_retries: Number of times to retry after timeout when
526
+ waiting for extract to be generated by PMHC website.
527
+ kwargs: Additional arguments passed through to
528
+ requests.get()
529
+
530
+ Returns:
531
+ requests.Response
532
+
533
+ Examples:
534
+
535
+ Stream download to disk, avoiding excessive RAM usage.
536
+
537
+ >>> with PMHC("PHN105") as pmhc:
538
+ ... pmhc.login()
539
+ ... r = pmhc.download_extract_request(stream=True)
540
+ ... with open('extract.zip', 'wb') as file:
541
+ ... for content in r.iter_content(chunk_size=65536):
542
+ ... file.write(content)
543
+ """
544
+
545
+ if organisation_path is None:
546
+ organisation_path = self.organisation_path
547
+
548
+ # Queue download from PMHC
549
+ logging.info("Queuing extract...")
550
+ params = {
551
+ "organisation_path": f"{organisation_path}",
552
+ "encoded_organisation_path": f"{organisation_path}",
553
+ "file_type": "csv",
554
+ "start_date": f"{start_date:%Y-%m-%d}",
555
+ "end_date": f"{end_date:%Y-%m-%d}",
556
+ # These need to be interpreted as a JS boolean
557
+ # (true or 1, rather than True).
558
+ "childless": int(without_associated_dates),
559
+ "all_episode_children": int(matched_episodes),
560
+ "spec_type": specification.term,
561
+ }
562
+
563
+ download_request = self.s.get(
564
+ "https://pmhc-mds.net/api/extract/csv",
565
+ params=params,
566
+ )
567
+ download_response = download_request.json()
568
+ try:
569
+ download_uuid = download_response["uuid"]
570
+ except KeyError as err:
571
+ progress.stop()
572
+ logging.error("Could not find uuid in the following JSON:")
573
+ logging.error(download_response)
574
+ logging.error(
575
+ "Ensure your PMHC user has the 'Reporting' role and you have\n"
576
+ "set the correct organisation_path."
577
+ )
578
+ raise err
579
+
580
+ # Wait for extract to be ready
581
+ logging.info("Waiting for extract...")
582
+ self.wait_for_extract(download_uuid, max_retries)
583
+
584
+ # We know the URL which will give us the final download URL,
585
+ # as we have the uuid. We have confirmed above that the
586
+ # extract is completed.
587
+ retries = 0
588
+ while retries < max_retries:
589
+ try:
590
+ download_url_request = self.s.get(
591
+ f"https://pmhc-mds.net/api/extract/{download_uuid}/fetch"
592
+ )
593
+ # Successful status codes are between 200 and 299
594
+ if 200 <= download_url_request.status_code <= 299:
595
+ break
596
+ except (requests.ReadTimeout, requests.ConnectionError) as err:
597
+ retries += 1
598
+ logging.warning(err)
599
+ logging.warning(
600
+ f"Request timed out ({retries} of {max_retries}). Retrying."
601
+ )
602
+
603
+ # Wait before retrying
604
+ time.sleep(30)
605
+
606
+ else:
607
+ raise MaxRetriesExceeded(f"Tried fetching PMHC extract {retries} times.")
608
+
609
+ download_url_json = download_url_request.json()
610
+ download_url = download_url_json["location"]
611
+
612
+ logging.info("Downloading extract...")
613
+ return self.s.get(download_url, **kwargs)
614
+
615
+ def download_pmhc_mds(
616
+ self,
617
+ output_directory: Path = Path("."),
618
+ start_date: date = date.today() - timedelta(days=30),
619
+ end_date: date = date.today(),
620
+ organisation_path: Optional[str] = None,
621
+ specification: PMHCSpecification = PMHCSpecification.PMHC,
622
+ without_associated_dates: bool = False,
623
+ matched_episodes: bool = False,
624
+ max_retries: int = 20,
625
+ ) -> Path:
626
+ """Extract PMHC MDS Data within the date range. If no date range
627
+ is given, `start_date` defaults to 30 days before the current
628
+ date and `end_date` defaults to the current date.
629
+
630
+ If you just want the raw requests.Response object, use the
631
+ download_extract_request() method.
632
+
633
+ Args:
634
+ output_directory: directory to save download
635
+ start_date: start date for extract
636
+ end_date: end date for extract (default: today)
637
+ organisation_path: Organisation path for downloaded extract.
638
+ Defaults to your organisation as specified when
639
+ initialising `pmhclib.PMHC`. However, can be a different
640
+ organisation, for example if you are a PHN, but only
641
+ want to download data for a single provider
642
+ organisation.
643
+ specification: Specification for extract. (default:
644
+ `PMHCSpecification.PMHC`, which returns data from the
645
+ current PMHC specification.)
646
+ without_associated_dates: Enable extract option
647
+ "Include data without associated dates"
648
+ matched_episodes: Enable extract option
649
+ "Include all data associated with matched episodes"
650
+ max_retries: Number of times to retry after timeout when
651
+ waiting for extract to be generated by PMHC website.
652
+
653
+ Returns:
654
+ Path to downloaded extract.
655
+ """
656
+
657
+ output_file = output_directory / f"pmhc_extract_{start_date}_{end_date}.zip"
658
+ logging.info(f"Saving output to {output_file}")
659
+
660
+ r = self.download_extract_request(
661
+ start_date=start_date,
662
+ end_date=end_date,
663
+ organisation_path=organisation_path,
664
+ specification=specification,
665
+ without_associated_dates=without_associated_dates,
666
+ matched_episodes=matched_episodes,
667
+ max_retries=max_retries,
668
+ stream=True,
669
+ )
670
+
671
+ with open(output_file, "wb") as fp:
672
+ for content in r.iter_content(chunk_size=65536):
673
+ fp.write(content)
674
+
675
+ return output_file