Skip to content

Commit b10bd6b

Browse files
authored
Merge pull request #29 from itk-dev-rpa/release/1.2.0
Release/1.2.0
2 parents 10ae378 + 5090cb6 commit b10bd6b

File tree

19 files changed

+1214
-2
lines changed

19 files changed

+1214
-2
lines changed

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,11 @@ Some examples are:
3737
- List and get emails.
3838
- Get attachment data.
3939
- Move and delete emails.
40+
41+
### KMD Nova
42+
43+
Helper functions for using the KMD Nova api.
44+
Some examples are:
45+
46+
- Get cases and documents.
47+
- Create cases, documents and tasks.

changelog.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [1.2.0] - 2024-02-12
11+
12+
### Added
13+
14+
- misc/cpr_util: Function to get age from cpr number.
15+
- Hooks for KMD Nova cases.
16+
- Hooks for KMD Nova tasks.
17+
- Hooks for KMD Nova documents.
18+
- Hooks for CPR address lookup via KMD Nova API.
19+
- Tests for all of the above.
20+
1021
## [1.1.0] - 2023-11-28
1122

1223
### Changed
@@ -32,6 +43,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3243

3344
- Initial release
3445

35-
[Unreleased] https://github.com/itk-dev-rpa/ITK-dev-shared-components/compare/1.1.0...HEAD
46+
[Unreleased] https://github.com/itk-dev-rpa/ITK-dev-shared-components/compare/1.2.0...HEAD
47+
[1.2.0] https://github.com/itk-dev-rpa/ITK-dev-shared-components/releases/tag/1.2.0
3648
[1.1.0] https://github.com/itk-dev-rpa/ITK-dev-shared-components/releases/tag/1.1.0
3749
[1.0.0] https://github.com/itk-dev-rpa/ITK-dev-shared-components/releases/tag/1.0.0
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"""This module provides helper functions to use the KMD Nova api.
2+
The api is documented here: https://novaapi.kmd.dk/swagger/index.html
3+
"""
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""This module contains functionality to authenticate against the KMD Nova api."""
2+
3+
from datetime import datetime, timedelta
4+
import urllib
5+
6+
import requests
7+
8+
9+
# pylint: disable-next=too-few-public-methods
10+
class NovaAccess:
11+
"""An object that handles access to the KMD Nova api."""
12+
def __init__(self, client_id: str, client_secret: str, domain: str = "https://cap-novaapi.kmd.dk") -> None:
13+
self.client_id = client_id
14+
self.client_secret = client_secret
15+
self._bearer_token, self.token_expiry_date = self._get_new_token()
16+
self.domain = domain
17+
18+
def _get_new_token(self) -> tuple[str, datetime]:
19+
"""
20+
This method requests a new token from the API.
21+
When the token expiry date is passed, requests with the token to the API will return HTTP 401 status code.
22+
23+
Returns:
24+
tuple: token and expiry datetime
25+
26+
Raises:
27+
requests.exceptions.HTTPError: If the request failed.
28+
"""
29+
30+
url = "https://novaauth.kmd.dk/realms/NovaIntegration/protocol/openid-connect/token"
31+
payload = {
32+
"client_secret": self.client_secret,
33+
"grant_type": "client_credentials",
34+
"client_id": self.client_id,
35+
"scope": "client"
36+
}
37+
payload = urllib.parse.urlencode(payload)
38+
headers = {'Content-Type': 'application/x-www-form-urlencoded'}
39+
40+
response = requests.post(url, headers=headers, data=payload, timeout=60)
41+
response.raise_for_status()
42+
response_json = response.json()
43+
bearer_token = response_json['access_token']
44+
token_life_seconds = response_json['expires_in']
45+
token_expiry_date = datetime.now() + timedelta(seconds=int(token_life_seconds))
46+
return bearer_token, token_expiry_date
47+
48+
def get_bearer_token(self) -> str:
49+
"""Return the bearer token. If the token is about to expire,
50+
a new token is requested form the auth service.
51+
52+
Returns:
53+
Bearer token
54+
"""
55+
56+
if self.token_expiry_date + timedelta(seconds=30) < datetime.now():
57+
self._bearer_token, self.token_expiry_date = self._get_new_token()
58+
59+
return self._bearer_token
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"""This module has functions to do with cpr related calls
2+
to the KMD Nova api."""
3+
4+
import uuid
5+
import urllib.parse
6+
7+
import requests
8+
9+
from itk_dev_shared_components.kmd_nova.authentication import NovaAccess
10+
11+
12+
def get_address_by_cpr(cpr: str, nova_access: NovaAccess) -> dict:
13+
"""Gets the street address of a citizen by their CPR number.
14+
15+
Args:
16+
cpr: CPR number of the citizen.
17+
18+
Returns:
19+
A dict with the address information.
20+
21+
Raises:
22+
requests.exceptions.HTTPError: If the request failed.
23+
"""
24+
25+
url = urllib.parse.urljoin(nova_access.domain, "api/Cpr/GetAddressByCpr")
26+
params = {
27+
"TransactionId": str(uuid.uuid4()),
28+
"Cpr": cpr,
29+
"api-version": "1.0-Cpr"
30+
}
31+
headers = {'Authorization': f"Bearer {nova_access.get_bearer_token()}"}
32+
33+
response = requests.get(url, params=params, headers=headers, timeout=60)
34+
response.raise_for_status()
35+
address = response.json()
36+
return address
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
"""This module has functions to do with case related calls
2+
to the KMD Nova api."""
3+
4+
import uuid
5+
import base64
6+
import urllib.parse
7+
8+
import requests
9+
10+
from itk_dev_shared_components.kmd_nova.authentication import NovaAccess
11+
from itk_dev_shared_components.kmd_nova.nova_objects import NovaCase, CaseParty, JournalNote
12+
from itk_dev_shared_components.kmd_nova.util import datetime_from_iso_string
13+
14+
15+
def get_cases(nova_access: NovaAccess, cpr: str = None, case_number: str = None, case_title: str = None, limit: int = 100) -> list[NovaCase]:
16+
"""Search for cases on different search terms.
17+
Currently supports search on cpr number, case number and case title. At least one search term must be given.
18+
19+
Args:
20+
nova_access: The NovaAccess object used to authenticate.
21+
cpr: The cpr number to search on. E.g. "0123456789"
22+
case_number: The case number to search on. E.g. "S2022-12345"
23+
case_title: The case title to search on.
24+
limit: The maximum number of cases to find (1-500).
25+
26+
Returns:
27+
A list of NovaCase objects.
28+
29+
Raises:
30+
ValueError: If no search terms are given.
31+
requests.exceptions.HTTPError: If the request failed.
32+
"""
33+
34+
if not any((cpr, case_number, case_title)):
35+
raise ValueError("No search terms given.")
36+
37+
url = urllib.parse.urljoin(nova_access.domain, "api/Case/GetList")
38+
params = {"api-version": "1.0-Case"}
39+
40+
payload = {
41+
"common": {
42+
"transactionId": str(uuid.uuid4())
43+
},
44+
"paging": {
45+
"startRow": 1,
46+
"numberOfRows": limit
47+
},
48+
"caseAttributes": {
49+
"userFriendlyCaseNumber": case_number,
50+
"title": case_title
51+
},
52+
"caseParty": {
53+
"identificationType": "CprNummer",
54+
"identification": cpr
55+
},
56+
"caseGetOutput": {
57+
"numberOfSecondaryParties": True,
58+
"caseParty": {
59+
"identificationType": True,
60+
"identification": True,
61+
"participantRole": True,
62+
"name": True,
63+
"index": True
64+
},
65+
"caseAttributes": {
66+
"title": True,
67+
"userFriendlyCaseNumber": True,
68+
"caseDate": True
69+
},
70+
"state": {
71+
"activeCode": True,
72+
"progressState": True
73+
},
74+
"numberOfDocuments": True,
75+
"numberOfJournalNotes": True,
76+
"caseClassification": {
77+
"kleNumber": {
78+
"code": True
79+
},
80+
"proceedingFacet": {
81+
"code": True
82+
}
83+
},
84+
"sensitivity": {
85+
"sensitivity": True
86+
}
87+
}
88+
}
89+
90+
headers = {'Content-Type': 'application/json', 'Authorization': f"Bearer {nova_access.get_bearer_token()}"}
91+
92+
response = requests.put(url, params=params, headers=headers, json=payload, timeout=60)
93+
response.raise_for_status()
94+
95+
if response.json()['pagingInformation']['numberOfRows'] == 0:
96+
return []
97+
98+
# Convert json to NovaCase objects
99+
cases = []
100+
for case_dict in response.json()['cases']:
101+
case = NovaCase(
102+
uuid = case_dict['common']['uuid'],
103+
title = case_dict['caseAttributes']['title'],
104+
case_date = datetime_from_iso_string(case_dict['caseAttributes']['caseDate']),
105+
case_number = case_dict['caseAttributes']['userFriendlyCaseNumber'],
106+
active_code = case_dict['state']['activeCode'],
107+
progress_state = case_dict['state']['progressState'],
108+
case_parties = _extract_case_parties(case_dict),
109+
document_count = case_dict['numberOfDocuments'],
110+
note_count = case_dict['numberOfJournalNotes'],
111+
kle_number = case_dict['caseClassification']['kleNumber']['code'],
112+
proceeding_facet = case_dict['caseClassification']['proceedingFacet']['code'],
113+
sensitivity = case_dict["sensitivity"]["sensitivity"]
114+
)
115+
116+
cases.append(case)
117+
118+
return cases
119+
120+
121+
def _extract_case_parties(case_dict: dict) -> list[CaseParty]:
122+
"""Extract the case parties from a HTTP request response.
123+
124+
Args:
125+
case_dict: The dictionary describing the case party.
126+
127+
Returns:
128+
A case party object describing the case party.
129+
"""
130+
parties = []
131+
for party_dict in case_dict['caseParties']:
132+
party = CaseParty(
133+
uuid = party_dict['index'],
134+
identification_type = party_dict['identificationType'],
135+
identification = party_dict['identification'],
136+
role = party_dict['participantRole'],
137+
name = party_dict.get('name', None)
138+
)
139+
parties.append(party)
140+
141+
return parties
142+
143+
144+
def _extract_journal_notes(case_dict: dict) -> list:
145+
"""Extract the journal notes from a HTTP request response.
146+
147+
Args:
148+
case_dict: The dictionary describing the journal note.
149+
150+
Returns:
151+
A journal note object describing the journal note.
152+
"""
153+
notes = []
154+
for note_dict in case_dict['journalNotes']['journalNotes']:
155+
note = JournalNote(
156+
uuid = note_dict['uuid'],
157+
title = note_dict['journalNoteAttributes']['title'],
158+
journal_date = note_dict['journalNoteAttributes']['journalNoteDate'],
159+
note_format = note_dict['journalNoteAttributes']['format'],
160+
note = base64.b64decode(note_dict['journalNoteAttributes']['note']),
161+
approved = note_dict['journalNoteAttributes'].get('approved', False)
162+
)
163+
notes.append(note)
164+
return notes
165+
166+
167+
def add_case(case: NovaCase, nova_access: NovaAccess, security_unit_id: int = 818485, security_unit_name: str = "Borgerservice"):
168+
"""Add a case to KMD Nova. The case will be created as 'Active'.
169+
170+
Args:
171+
case: The case object describing the case.
172+
nova_access: The NovaAccess object used to authenticate.
173+
security_unit_id: The id of the security unit that has access to the case. Defaults to 818485.
174+
security_unit_name: The name of the security unit that has access to the case. Defaults to "Borgerservice".
175+
176+
Raises:
177+
requests.exceptions.HTTPError: If the request failed.
178+
"""
179+
url = urllib.parse.urljoin(nova_access.domain, "api/Case/Import")
180+
params = {"api-version": "1.0-Case"}
181+
182+
payload = {
183+
"common": {
184+
"transactionId": str(uuid.uuid4()),
185+
"uuid": case.uuid
186+
},
187+
"caseAttributes": {
188+
"title": case.title,
189+
"caseDate": case.case_date.isoformat()
190+
},
191+
"caseClassification": {
192+
"kleNumber": {
193+
"code": case.kle_number
194+
},
195+
"proceedingFacet": {
196+
"code": case.proceeding_facet
197+
}
198+
},
199+
"state": case.progress_state,
200+
"sensitivity": case.sensitivity,
201+
"caseParties": [
202+
{
203+
"name": party.name,
204+
"identificationType": party.identification_type,
205+
"identification": party.identification,
206+
"participantRole": party.role
207+
} for party in case.case_parties
208+
],
209+
"securityUnit": {
210+
"losIdentity": {
211+
"administrativeUnitId": security_unit_id,
212+
"fullName": security_unit_name,
213+
}
214+
},
215+
"SensitivityCtrlBy": "Bruger",
216+
"SecurityUnitCtrlBy": "Regler",
217+
"ResponsibleDepartmentCtrlBy": "Regler",
218+
"caseAvailability": {
219+
"unit": "År",
220+
"scale": 5
221+
},
222+
"AvailabilityCtrlBy": "Regler"
223+
}
224+
225+
headers = {'Content-Type': 'application/json', 'Authorization': f"Bearer {nova_access.get_bearer_token()}"}
226+
227+
response = requests.post(url, params=params, headers=headers, json=payload, timeout=60)
228+
response.raise_for_status()

0 commit comments

Comments
 (0)