Skip to content

Commit 8492c1a

Browse files
authored
Merge pull request #66 from itk-dev-rpa/2.5.0
2.5.0
2 parents 43d2ebc + c093c6f commit 8492c1a

File tree

18 files changed

+880
-25
lines changed

18 files changed

+880
-25
lines changed

changelog.md

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88

99
## [Unreleased]
1010

11+
## [2.5.0] - 2024-08-14
12+
1113
### Added
1214

13-
### Changed
15+
- Added modules for use with the Eflyt / Dedalus / Notus address system.
16+
- misc.file_util: handle_save_dialog.
17+
- misc.cvr_lookup: Look up cvr number.
18+
- misc.address_lookup: Look up addresses.
19+
20+
### Fixed
21+
22+
- Conversion of ÆØÅ to Ae Oe Aa when uploading notes to Nova.
1423

1524
## [2.4.0] - 2024-07-30
1625

1726
### Added
1827

19-
- Caseworker for notes
20-
- Directions for setting up environment variables for test
28+
- Caseworker for notes.
29+
- Directions for setting up environment variables for test.
2130
- misc.file_util: Wait for download function.
2231

2332
### Changed
@@ -136,7 +145,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
136145

137146
- Initial release
138147

139-
[Unreleased]: https://github.com/itk-dev-rpa/ITK-dev-shared-components/compare/2.4.0...HEAD
148+
[Unreleased]: https://github.com/itk-dev-rpa/ITK-dev-shared-components/compare/2.5.0...HEAD
149+
[2.5.0]: https://github.com/itk-dev-rpa/ITK-dev-shared-components/releases/tag/2.5.0
140150
[2.4.0]: https://github.com/itk-dev-rpa/ITK-dev-shared-components/releases/tag/2.4.0
141151
[2.3.0]: https://github.com/itk-dev-rpa/ITK-dev-shared-components/releases/tag/2.3.0
142152
[2.2.0]: https://github.com/itk-dev-rpa/ITK-dev-shared-components/releases/tag/2.2.0

itk_dev_shared_components/eflyt/__init__.py

Whitespace-only changes.
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
"""Module for handling cases in eFlyt"""
2+
from dataclasses import dataclass
3+
from datetime import date, datetime
4+
5+
from selenium import webdriver
6+
from selenium.webdriver.common.by import By
7+
8+
9+
@dataclass
10+
class Case:
11+
"""A dataclass representing an Eflyt case."""
12+
case_number: str
13+
deadline: date | None
14+
case_types: list[str]
15+
16+
17+
@dataclass
18+
class Inhabitant:
19+
"""A dataclass representing an inhabitant."""
20+
cpr: str
21+
name: str
22+
move_in_date: date
23+
relations: list[str]
24+
25+
26+
@dataclass
27+
class Applicant:
28+
"""A dataclass representing an applicant."""
29+
cpr: str
30+
name: str
31+
32+
33+
def get_beboere(browser: webdriver.Chrome) -> list[Inhabitant]:
34+
"""Get a list of current inhabitants on the case currently open.
35+
36+
Args:
37+
browser: The webdriver browser object.
38+
39+
Returns:
40+
A list of Inhabitants.
41+
"""
42+
# Go to the correct tab and scrape data
43+
change_tab(browser, 1)
44+
beboer_table = browser.find_element(By.ID, "ctl00_ContentPlaceHolder2_ptFanePerson_becPersonTab_GridViewBeboere")
45+
rows = beboer_table.find_elements(By.TAG_NAME, "tr")
46+
47+
# Remove header
48+
rows.pop(0)
49+
50+
inhabitants = []
51+
for inhabitant in rows:
52+
# Get date for moving in, CPR and name
53+
moving_in = datetime.strptime(inhabitant.find_element(By.XPATH, "td[1]/span | td[1]/a").text, "%d-%m-%Y").date()
54+
cpr = inhabitant.find_element(By.XPATH, "td[2]").text.replace("-", "")
55+
name = inhabitant.find_element(By.XPATH, "td[3]").text
56+
57+
# Check for a list of relations
58+
relations = []
59+
elements = inhabitant.find_elements(By.XPATH, "td[4]/span")
60+
if elements:
61+
relations = elements[0].text.replace("<br>", ";").replace("\n", ";").split(";")
62+
63+
# Create an Inhabitant and add to list
64+
new_inhabitant = Inhabitant(cpr, name, moving_in, relations)
65+
inhabitants.append(new_inhabitant)
66+
67+
return inhabitants
68+
69+
70+
def get_room_count(browser: webdriver.Chrome) -> int:
71+
"""Get the number of rooms on the address.
72+
73+
Args:
74+
browser: The webdriver browser object.
75+
76+
Returns:
77+
The number of rooms on the address.
78+
"""
79+
change_tab(browser, 1)
80+
area_room_text = browser.find_element(By.ID, "ctl00_ContentPlaceHolder2_ptFanePerson_stcPersonTab6_lblAreaText").text
81+
room_text = area_room_text.split("/")[1]
82+
return int(room_text)
83+
84+
85+
def get_applicants(browser: webdriver.Chrome) -> list[Applicant]:
86+
"""Get a list of applicants' cpr numbers from the applicant table.
87+
88+
Args:
89+
browser: The webdriver browser object.
90+
91+
Returns:
92+
A list of cpr numbers.
93+
"""
94+
table = browser.find_element(By.ID, "ctl00_ContentPlaceHolder2_GridViewMovingPersons")
95+
rows = table.find_elements(By.TAG_NAME, "tr")
96+
97+
# Remove header row
98+
rows.pop(0)
99+
100+
applicants = []
101+
102+
for row in rows:
103+
cpr = row.find_element(By.XPATH, "td[2]/a[2]").text.replace("-", "")
104+
name = row.find_element(By.XPATH, "td[3]/a").text
105+
applicant = Applicant(cpr, name)
106+
applicants.append(applicant)
107+
108+
return applicants
109+
110+
111+
def change_tab(browser: webdriver.Chrome, tab_index: int):
112+
"""Change the tab in the case view e.g. 'Sagslog', 'Breve'.
113+
114+
Args:
115+
browser: The webdriver browser object.
116+
tab_index: The zero-based index of the tab to select.
117+
"""
118+
browser.execute_script(f"__doPostBack('ctl00$ContentPlaceHolder2$ptFanePerson$ImgJournalMap','{tab_index}')")
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"""Module for logging into Eflyt/Daedalus/Whatchamacallit using Selenium"""
2+
from selenium import webdriver
3+
from selenium.webdriver.common.by import By
4+
from selenium.common.exceptions import NoSuchElementException
5+
6+
7+
def login(username: str, password: str) -> webdriver.Chrome:
8+
"""Log into Eflyt using a password and username.
9+
10+
Args:
11+
username: Username for login.
12+
password: Password for login.
13+
"""
14+
chrome_options = webdriver.ChromeOptions()
15+
chrome_options.add_argument("--disable-search-engine-choice-screen")
16+
browser = webdriver.Chrome(options=chrome_options)
17+
browser.maximize_window()
18+
19+
browser.get("https://notuskommunal.scandihealth.net/")
20+
browser.find_element(By.ID, "Login1_UserName").send_keys(username)
21+
browser.find_element(By.ID, "Login1_Password").send_keys(password)
22+
browser.find_element(By.ID, "Login1_LoginImageButton").click()
23+
24+
try:
25+
browser.find_element(By.ID, "ctl00_imgLogo")
26+
except NoSuchElementException as exc:
27+
raise RuntimeError("Login failed") from exc
28+
29+
return browser
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"""Interface for working with the 'Digital Flytning' section of Eflyt"""
2+
from datetime import date, datetime
3+
from typing import Literal
4+
5+
from selenium import webdriver
6+
from selenium.webdriver.common.by import By
7+
from selenium.webdriver.support.select import Select
8+
9+
from itk_dev_shared_components.eflyt.eflyt_util import format_date
10+
from itk_dev_shared_components.eflyt.eflyt_case import Case
11+
12+
CaseState = Literal[
13+
"Alle",
14+
"Afsluttet",
15+
"Fraflytning",
16+
"I gang",
17+
"Ubehandlet"
18+
]
19+
CaseStatus = Literal[
20+
"(vælg status)",
21+
"Afsluttet",
22+
"Afventer CPR",
23+
"Afvist",
24+
"Fejl",
25+
"Godkendt",
26+
"I gang",
27+
"Partshøring",
28+
"Sendt til CPR",
29+
"Svarfrist overskredet",
30+
"Ubehandlet"
31+
]
32+
33+
34+
def search(browser: webdriver.Chrome, from_date: date | None = None, to_date: date | None = None, case_state: CaseState = "Alle", case_status: CaseStatus = "(vælg status)"):
35+
"""Apply the correct filters in Eflyt and search the case list.
36+
37+
Args:
38+
browser: The webdriver browser object.
39+
"""
40+
browser.get("https://notuskommunal.scandihealth.net/web/SearchResulteFlyt.aspx")
41+
Select(browser.find_element(By.ID, "ctl00_ContentPlaceHolder1_SearchControl_ddlTilstand")).select_by_visible_text(case_state)
42+
Select(browser.find_element(By.ID, "ctl00_ContentPlaceHolder1_SearchControl_ddlStatus")).select_by_visible_text(case_status)
43+
if from_date:
44+
browser.find_element(By.ID, "ctl00_ContentPlaceHolder1_SearchControl_txtFlytteStartDato").send_keys(format_date(from_date))
45+
if to_date:
46+
browser.find_element(By.ID, "ctl00_ContentPlaceHolder1_SearchControl_txtFlytteEndDato").send_keys(format_date(to_date))
47+
browser.find_element(By.XPATH, '//input[contains(@id, "earchControl_btnSearch")]').click()
48+
49+
50+
def extract_cases(browser: webdriver.Chrome) -> list[Case]:
51+
"""Extract and filter cases from the case table. Requires a search to have been performed immediately before.
52+
53+
Args:
54+
browser: The webdriver browser object.
55+
56+
Returns:
57+
A list of filtered case objects.
58+
"""
59+
table = browser.find_element(By.ID, "ctl00_ContentPlaceHolder2_GridViewSearchResult")
60+
rows = table.find_elements(By.TAG_NAME, "tr")
61+
# Remove header row
62+
rows.pop(0)
63+
cases = []
64+
for row in rows:
65+
deadline = row.find_element(By.XPATH, "td[3]/a").text
66+
67+
# Convert deadline to date object
68+
if deadline:
69+
deadline = datetime.strptime(deadline, "%d-%m-%Y")
70+
else:
71+
deadline = None
72+
73+
case_number = row.find_element(By.XPATH, "td[4]").text
74+
case_types_text = row.find_element(By.XPATH, "td[5]").text
75+
76+
# If the case types ends with '...' we need to get the title instead
77+
if case_types_text.endswith("..."):
78+
case_types_text = row.find_element(By.XPATH, "td[5]").get_attribute("Title")
79+
80+
case_types = case_types_text.split(", ")
81+
case = Case(case_number, deadline, case_types)
82+
cases.append(case)
83+
84+
return cases
85+
86+
87+
def open_case(browser: webdriver.Chrome, case: str):
88+
"""Open a case by searching for its case number.
89+
90+
Args:
91+
browser: The webdriver browser object.
92+
case: The case to open.
93+
"""
94+
# The id for both the search field and search button changes based on the current view hence the weird selectors.
95+
browser.get("https://notuskommunal.scandihealth.net/web/SearchResulteFlyt.aspx")
96+
case_input = browser.find_element(By.ID, 'ctl00_ContentPlaceHolder1_SearchControl_txtSagNr')
97+
case_input.clear()
98+
case_input.send_keys(case)
99+
100+
browser.find_element(By.ID, 'ctl00_ContentPlaceHolder1_SearchControl_btnSearch').click()
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""General helper funtions"""
2+
from datetime import date
3+
4+
5+
def format_date(_date: date) -> str:
6+
"""Format date as %d-%m-%Y"""
7+
return _date.strftime("%d-%m-%Y")

itk_dev_shared_components/kmd_nova/nova_notes.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,13 +69,16 @@ def _encode_text(string: str) -> str:
6969
Ensure the base64 string doesn't contain padding by inserting spaces at the end of the input string.
7070
There is a bug in the Nova api that corrupts the string if it contains padding.
7171
The extra spaces will not show up in the Nova user interface.
72+
A necessary hack has been implemented to convert Æ, Ø and Å to ae, oe and aa.
7273
7374
Args:
7475
string: The string to encode.
7576
7677
Returns:
7778
A base64 string containing no padding.
7879
"""
80+
string = string.replace('æ', 'ae').replace('ø', 'oe').replace('å', 'aa').replace('Æ', 'Ae').replace('Ø', 'Oe').replace('Å', 'Aa')
81+
7982
def b64(s: str) -> str:
8083
"""Helper function to convert a string to base64."""
8184
return base64.b64encode(s.encode()).decode()
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""This module contains functions to look up addresses in DAWA.
2+
https://dawadocs.dataforsyningen.dk/dok/api/
3+
"""
4+
5+
from dataclasses import dataclass, field
6+
7+
import requests
8+
9+
10+
@dataclass
11+
# pylint: disable-next=too-many-instance-attributes
12+
class Address:
13+
"""A dataclass representing an address."""
14+
street: str
15+
number: str
16+
floor: str
17+
door: str
18+
minor_city: str
19+
postal_city: str
20+
postal_code: str
21+
municipality_code: str
22+
address_text: str
23+
id: str = field(repr=False)
24+
25+
26+
def search_address(query: str | None = None, street: str | None = None, number: str | None = None,
27+
postal_code: str | None = None, municipality_code: str | None = None,
28+
results_per_page: int = 100, page: int = 1) -> list[Address]:
29+
"""Search for an address in the DAWA API.
30+
31+
Args:
32+
query: The free text to search for.
33+
street: The exact street of the address.
34+
number: The exact number of the address.
35+
postal_code: The exact postal code of the address.
36+
municipality_code: The exact municipality code of the address.
37+
results_per_page: The number of results to fetch per page.
38+
page: The 1-based page of results to fetch.
39+
40+
Returns:
41+
A list of address objects describing the addresses found.
42+
"""
43+
url = "https://api.dataforsyningen.dk/adresser"
44+
45+
params = {
46+
'struktur': 'mini',
47+
'per_side': results_per_page,
48+
'side': page
49+
}
50+
if query:
51+
params['q'] = query
52+
if street:
53+
params['vejnavn'] = street
54+
if number:
55+
params['husnr'] = number
56+
if postal_code:
57+
params['postnr'] = postal_code
58+
if municipality_code:
59+
params['kommunekode'] = municipality_code
60+
61+
response = requests.get(url, params=params, timeout=10)
62+
response.raise_for_status()
63+
64+
addresses = []
65+
for a in response.json():
66+
address = Address(
67+
street=a['vejnavn'],
68+
number=a['husnr'],
69+
floor=a['etage'],
70+
door=a['dør'],
71+
minor_city=a['supplerendebynavn'],
72+
postal_city=a['postnrnavn'],
73+
postal_code=a['postnr'],
74+
municipality_code=a['kommunekode'],
75+
address_text=a['betegnelse'],
76+
id=a['id']
77+
)
78+
addresses.append(address)
79+
80+
return addresses

0 commit comments

Comments
 (0)