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 +22 -0
- pmhclib-0.9.0/PKG-INFO +151 -0
- pmhclib-0.9.0/README.md +130 -0
- pmhclib-0.9.0/pyproject.toml +27 -0
- pmhclib-0.9.0/src/pmhclib/__init__.py +10 -0
- pmhclib-0.9.0/src/pmhclib/pmhc.py +675 -0
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
|
+

|
|
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
|
+
|
pmhclib-0.9.0/README.md
ADDED
|
@@ -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
|
+

|
|
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
|