` installiert werden, z.B. `pip3 install h5py`.
26 |
27 | ## Abruf und Archivierung
28 | Abruf und Archivierung erfolgen durch das Skript `cron.py`, das alle fünf Minuten durch einen Cronjob ausgeführt werden sollte. Um möglichst die aktuellsten Daten abzugreifen, empfiehlt sich ein minimaler Versatz zum Bereitstellungszeitpunkt der Daten:
29 | ```
30 | # m h dom mon dow command
31 | 2-59/5 * * * * /bin/bash -c 'source myenv/bin/activate && python cron.py'
32 | ```
33 | Die Daten werden aufbereitet und in der Datei `data.hdf5` gespeichert. Existiert diese noch nicht, wird sie beim ersten Ausführen des Skriptes `cron.py` automatisch erzeugt.
34 |
35 | Innerhalb der Datei wird folgende Gruppenstruktur aufgebaut:
36 |
37 | ```
38 | .
39 | +-- availability
40 | | +-- CI-0000001
41 | | +-- CI-0000002
42 | | +-- ...
43 | +-- configuration_items
44 | +-- CI-0000001
45 | +-- CI-0000002
46 | +-- ...
47 | ```
48 |
49 | Die Gruppen `availability` und `configuration_items` enthalten jeweils für jedes Konfigurationsobjekt (z.B. `CI-0000001`) eine gleichnamige Untergruppe.
50 |
51 | Die Untergruppe des Konfigurationsobjektes in der Gruppe `availability` enthält Datensätze mit der Verfügbarkeit als Integer (0: nicht verfügbar, 1: verfügbar). Der Name des Datensatzes entspricht der Unix-Zeit des Datenpunktes. Bei Aktualisierungen wird ein neuer Datensatz hinzugefügt.
52 |
53 | Die Untergruppe des Konfigurationsobjektes in der Gruppe `configuration_items` enthält mehrere Datensätze mit allegemeinen Eigenschaften wie `name`, `product` und `organization`. Außerdem die aktuelle Verfügbarkeit `current_availability` sowie die Veränderung der Verfügbarkeit `availability_difference` in Bezug auf den vorherigen Wert (-1: nicht mehr verfügbar, 0: keine Veränderung, 1: wieder verfügbar). Bei Aktualisierungen werden die vorhandenen Datensätze überschrieben.
54 |
55 | Je nach Systemleistung kann es sinnvoll sein, die Datei `data.hdf5` von Zeit zu Zeit archivieren. Hierzu kann die Datei beispielsweise per Cronjob in ein Archiv-Verzeichnis verschoben werden.
56 |
57 | ## Benachrichtigungen
58 |
59 | ### E-Mail
60 | Auf Wunsch können bei Änderungen der Verfügbarkeit Benachrichtigungen per E-Mail versendet werden. Dies geschieht ebenfalls über das Skrip `cron.py`, sofern in der Datei `myconfig.py` die Variable `notifications` den Wert `True` besitzt. Die SMTP-Verbindungsdaten werden ebenfalls in der Datei `myconfig.py` hinterlegt.
61 |
62 | In der Datei `notifications.json` können mehrere Profile definiert werden. Ein Profil besteht aus folgenden Eigenschaften:
63 |
64 | | Name | Beschreibung |
65 | | ----------- | ----------- |
66 | | name | Name des Profils (wird in der Anrede verwendet) |
67 | | recipients | Liste mit mindestens einer E-Mail-Adresse (z.B. `["mail1@example.com", "mail2@example.com"]`) |
68 | | ci_list | Liste von Konfigurationsobjekten (z.B. `["CI-000001", "CI-0000002"]`) |
69 | | type | entweder `blacklist` oder `whitelist` (legt fest, wie die Liste der Konfigurationsobjekte behandelt wird) |
70 | | push_topic | Thema für Push-Benachrichtigungen via ntfy (z.B. `TI`)|
71 |
72 | Hier ein Beispiel für eine E-Mail-Benachrichtigung:
73 | ")
74 |
75 | ### Push-Benachrichtigungen via ntfy
76 | Sofern eine [ntfy](https://github.com/binwiederhier/ntfy)-Instanz vorhanden ist, lassen sich optional Push-Benachrichtigungen versenden. Hierfür muss im Benachrichtigungsprofil ein Thema (`push_topic`) angegeben werden. Zudem sind in der Datei `myconfig.py` die URL zur ntfy-Instanz (`ntfy_url`) sowie ein gültiger Access-Token (`ntfy_token`) eines Users mit schreibenden Zugriff auf dieses Thema zu hinterlegen. Sofern die Variable `push_notifications` den Wert `True` besitzt, wird der Versand von Push-Benachrichtigungen über das Skrip `cron.py` angestoßen. Die Benachrichtigungen können über die mobilen ntfy-Apps ([Apple App Store](https://apps.apple.com/de/app/ntfy/id1625396347), [Google Play Store](https://play.google.com/store/apps/details?id=io.heckel.ntfy)) oder über kompatible Browser ausgespielt werden.
77 | ")
78 | **Hinweise:**
79 |
80 | * Um Push-Benachrichtigungen im Browser auch dann anzuzeigen, wenn lediglich die Seiten des TI-Monitorings geöffnet sind, ist die Adresse der ntfy-Instanz (`ntfy_url`) als unsichtbares Iframe in den Footer integriert.
81 |
82 | * Push-Benachrichtigungen beinhalten standardmäßig einen Action-Button, der zur Startseite der Web-App führt. Soll das TI-Monitoring ohne Web-App genutzt werden, kann in der Datei `myconfig.py` als `home_url` die Adresse einer öffentlichen TI-Monitoring-Instanz angegeben werden. Alternativ sollte der entsprechende Button aus der Funktion `send_push_notifications()` angepasst bzw. entfernt werden.
83 |
84 | * Soll die Web-App ohne Push-Benachrichtigungen genutzt werden, ist die Datei `pages/alerts.py` obsolet. Außerdem sollten der entsprechende Link und das Iframe aus dem Footer (siehe `app.py`) entfernt werden.
85 |
86 | ## Web-App
87 |
88 | Der aktuelle Status verschiedener Komponenten kann optional auch in Form einer Web-App auf Basis des [Dash-Frameworks](https://dash.plotly.com) bereitgestellt werden. Die App kann z.B. in Kombination mit uWSGi und nginx (ähnlich [wie hier beschrieben](https://carpiero.medium.com/host-a-dashboard-using-python-dash-and-linux-in-your-own-linux-server-85d891e960bc) veröffentlicht werden.
89 |
90 | Auf der Startseite der App werden die Komponenten nach Produkt gruppiert dargestellt. Durch Anklicken der Gruppen lassen sich die jeweiligen Komponenten einblenden.
91 | ")
92 | ")
93 | ")
94 | Per Klick auf die ID einer Komponente lässt sich eine Statistik der letzten Stunden aufrufen.
95 | ")
96 | Um eine gute Performance zu gewährleisten, kann das Zeitfenster der Statistik über die Variable `stats_delta_hours` in der Datei `myconfig.py` reduziert werden. Zudem kann es ratsam sein, die Datei `data.hdf5` regelmäßig zu archivieren bzw. zu leeren.
97 |
98 | Soll die Web-App überhaupt nicht genutzt werden, sind folgende Ordner bzw. Dateien irrelevant und können entfernt werden:
99 |
100 | * assets
101 | * pages
102 | * app.py
103 |
104 | ---
105 | **DISCLAIMER**
106 |
107 | Es handelt sich um ein privates Projekt ohne offiziellen Support. Jegliche Nutzung erfolgt auf eigene Verantwortung.
108 |
109 | Die Daten werden über eine öffentlich erreichbare Schnittstelle der gematik GmbH abgerufen. Eine ausführliche Beschreibung diser Schnittstelle ist öffentlich auf GitHub verfügbar: [https://github.com/gematik/api-tilage](https://github.com/gematik/api-tilage).
110 |
111 | ---
--------------------------------------------------------------------------------
/mylibrary.py:
--------------------------------------------------------------------------------
1 | # Import packages
2 | import numpy as np
3 | import pandas as pd
4 | import h5py as h5
5 | import requests, json, time, pytz, os
6 | from datetime import datetime
7 | from tzlocal import get_localzone
8 | import smtplib
9 | from email.message import EmailMessage
10 | from email.mime.text import MIMEText
11 |
12 | def initialize_data_file(file_name):
13 | """
14 | Creates hdf5 file if necessary and builds up basic group structure
15 |
16 | Args:
17 | file_name (str): Path to hdf5 file
18 |
19 | Returns:
20 | None
21 | """
22 | if not(os.path.isfile(file_name)):
23 | with h5.File(file_name, "w") as f:
24 | f.create_group("availability")
25 | f.create_group("configuration_items")
26 |
27 | def update_file(file_name, url):
28 | """
29 | Gets current data from API and updates hdf5 file
30 |
31 | Args:
32 | file_name (str): Path to hdf5 file
33 | url (str): URL of API
34 |
35 | Returns:
36 | None
37 | """
38 | data = json.loads(requests.get(url).text)
39 | df = pd.DataFrame(data)
40 | with h5.File(file_name, "a") as f:
41 | for idx in range(len(df)):
42 | ci = df.iloc[idx]
43 | # availybility
44 | group_av = f.require_group("availability/" + str(ci["ci"]))
45 | av = int(ci["availability"])
46 | utc_time = datetime.strptime(ci["time"], '%Y-%m-%dT%H:%M:%S.%fZ').replace(tzinfo=pytz.UTC)
47 | timestamp = utc_time.timestamp()
48 | ds = group_av.require_dataset(str(timestamp),shape=(),dtype=int)
49 | ds[()] = av
50 | # configuration items
51 | group_ci = f.require_group("configuration_items/" + str(ci["ci"]))
52 | str_256 = h5.string_dtype(encoding='utf-8', length=256)
53 | for property in ["tid", "bu", "organization", "pdt", "product", "name", "comment", "time"]:
54 | ds = group_ci.require_dataset(property, shape=(), dtype=str_256)
55 | ds[()] = ci[property]
56 | if "current_availability" in group_ci:
57 | prev_av = group_ci["current_availability"][()]
58 | av_diff = av - prev_av
59 | else:
60 | av_diff = 0
61 | ds = group_ci.require_dataset("availability_difference",shape=(),dtype=int)
62 | ds[()] = av_diff
63 | ds = group_ci.require_dataset("current_availability",shape=(),dtype=int)
64 | ds[()] = av
65 |
66 | def get_availability_data_of_ci(file_name, ci):
67 | """
68 | Gets availability data for a specific configuration item from hdf5 file
69 |
70 | Args:
71 | file_name (str): Path to hdf5 file
72 | ci (str): ID of the desired confirguration item
73 |
74 | Returns:
75 | DataFrame: Time series of the availability of the desired configuration item
76 | """
77 | all_ci_data = []
78 | with h5.File(file_name, 'r') as f:
79 | group = f["availability/" + ci]
80 | ci_data = {}
81 | times = []
82 | values = []
83 | for name, dataset in group.items():
84 | if isinstance(dataset, h5.Dataset):
85 | time = pd.to_datetime(float(name), unit='s').tz_localize('UTC').tz_convert('Europe/Berlin')
86 | times.append(time)
87 | values.append(int(dataset[()]))
88 | ci_data["times"] = np.array(times)
89 | ci_data["values"] = np.array(values)
90 | return pd.DataFrame(ci_data)
91 |
92 | def get_data_of_all_cis(file_name):
93 | """
94 | Gets general data for all configuration items from hdf5 file such as organization
95 | and product as well as current availability and availability difference
96 |
97 | Args:
98 | file_name (str): Path to hdf5 file
99 |
100 | Returns:
101 | DataFrame: Basic information about all configuration items
102 | """
103 | all_ci_data = []
104 | with h5.File(file_name, 'r') as f:
105 | group = f["configuration_items"]
106 | cis = group.keys()
107 | for ci in cis:
108 | group = f["configuration_items/"+ci]
109 | ci_data = {}
110 | ci_data["ci"] = ci
111 | for name in group:
112 | dataset = group[name]
113 | value = dataset[()]
114 | # Handle scalar bytes (decode)
115 | if isinstance(value, bytes):
116 | value = value.decode('utf-8')
117 | ci_data[name] = value
118 | #df = pd.DataFrame(ci_data)
119 | all_ci_data.append(ci_data)
120 | return pd.DataFrame(all_ci_data)
121 |
122 | def get_data_of_ci(file_name, ci):
123 | """
124 | Gets general data for a specific configuration item from hdf5 file
125 |
126 | Args:
127 | file_name (str): Path to hdf5 file
128 | ci (str): ID of the desired confirguration item
129 |
130 | Returns:
131 | DataFrame: General data of the desired configuration item
132 | """
133 | all_ci_data = []
134 | with h5.File(file_name, 'r') as f:
135 | group = f["configuration_items/"+ci]
136 | ci_data = {}
137 | ci_data["ci"] = ci
138 | for name in group:
139 | dataset = group[name]
140 | value = dataset[()]
141 | # Handle scalar bytes (decode)
142 | if isinstance(value, bytes):
143 | value = value.decode('utf-8')
144 | ci_data[name] = value
145 | return pd.DataFrame([ci_data])
146 |
147 | def pretty_timestamp(timestamp_str):
148 | """
149 | Converts UTC timestamp of API to pretty formatted timestamp in local time
150 |
151 | Args:
152 | timestamp_str (str): UTC timestamp from API
153 |
154 | Returns:
155 | str: pretty formatted timestamp in local time
156 | """
157 | utc_time = datetime.strptime(timestamp_str, '%Y-%m-%dT%H:%M:%S.%fZ')
158 | utc_time = utc_time.replace(tzinfo=pytz.UTC)
159 | berlin_time = utc_time.astimezone(pytz.timezone('Europe/Berlin'))
160 | formatted_time = berlin_time.strftime('%d.%m.%Y %H:%M:%S Uhr')
161 | return formatted_time
162 |
163 | def send_mail(smtp_settings, recipients, subject, html_message):
164 | """
165 | Sends a html-formatted mail to a specified reciepient using SMTP
166 |
167 | Args:
168 | smtp_settings (dict): host, port, username, password and sender address (from)
169 | recipients (list of str): list of email addresses
170 | subject (str): mail subject
171 | html_message (str): html content of mail
172 |
173 | Returns:
174 | None
175 | """
176 | msg = EmailMessage()
177 | msg.add_alternative(html_message, subtype='html')
178 | msg['Subject'] = subject
179 | msg['From'] = smtp_settings['from']
180 | msg['Bcc'] = recipients
181 | s = smtplib.SMTP(
182 | host = smtp_settings['host'],
183 | port = smtp_settings['port']
184 | )
185 | s.ehlo()
186 | s.starttls()
187 | s.login(
188 | user = smtp_settings['user'],
189 | password = smtp_settings['password']
190 | )
191 | s.send_message(msg)
192 | s.quit()
193 |
194 | def create_html_list_item_for_change(change, home_url):
195 | """
196 | Creates a html list item element for a configuration item with changed availability status
197 |
198 | Args:
199 | change (DataFrame): data for an individual configuration item containing information
200 | such as organization and product as well as current availability and availability difference
201 | home_url (str): base url of dash app
202 |
203 | Returns:
204 | str: html list item element
205 | """
206 | if home_url:
207 | href = home_url + '/plot?ci=' + str(change['ci'])
208 | else:
209 | href = ''
210 | html_str = '' + str(change['ci']) + ': ' + str(change['product']) + ', ' + str(change['name']) + ', ' + str(change['organization']) + ' '
211 | if change['availability_difference'] == 1:
212 | html_str += 'ist wieder verfügbar'
213 | elif change['availability_difference'] == -1:
214 | html_str += 'ist nicht mehr verfügbar'
215 | else:
216 | html_str += 'keine Veränderung'
217 | html_str += ', Stand: ' + str(pretty_timestamp(change['time'])) + ''
218 | return html_str
219 |
220 | def send_notifications(file_name, notifications_config_file, smtp_settings, home_url):
221 | """
222 | Sends email notifications for each notification configuration about all
223 | changes that are relevant for the respective configuration
224 |
225 | Args:
226 | file_name (str): Path to hdf5 file
227 | notifications_config_file (str): Path to json file with notification configurations
228 | smtp_settings (dict): host, port, username, password and sender address (from)
229 | home_url (str): base url of dash app
230 |
231 | Returns:
232 | None
233 | """
234 | # get notification config
235 | with open(notifications_config_file, 'r', encoding='utf-8') as f:
236 | notification_config = json.load(f)
237 | # get changes
238 | ci_data = get_data_of_all_cis(file_name)
239 | changes = ci_data[ci_data['availability_difference']!=0]
240 | changes_sorted = changes.sort_values(by = 'availability_difference')
241 | # filter relevant changes for each config and send mails
242 | for config in notification_config:
243 | try:
244 | if (config['type'] == 'whitelist'):
245 | relevant_changes = changes_sorted[changes_sorted['ci'].isin(config['ci_list'])]
246 | elif (config['type'] == 'blacklist'):
247 | relevant_changes = changes_sorted[~changes_sorted['ci'].isin(config['ci_list'])]
248 | number_of_relevant_changes = len(relevant_changes)
249 | if number_of_relevant_changes > 0:
250 | message = 'Hallo ' + str(config['name']) + ',
'
251 | message += 'bei der letzten Überprüfung hat sich die Verfügbarkeit der folgenden von Ihnen abonierten Komponenten geändert:
'
252 | for index, change in relevant_changes.iterrows():
253 | message += create_html_list_item_for_change(change, home_url)
254 | if home_url:
255 | message += '
Den aktuellen Status aller Komponenten können Sie unter ' + home_url + ' einsehen.
'
256 | message += 'Weitere Hintergrundinformationen finden Sie im Fachportal der gematik GmbH.
Viele Grüße
TI-Monitoring
'
257 | subject = 'TI-Monitoring: ' + str(number_of_relevant_changes) + ' Änderungen der Verfügbarkeit'
258 | recipients = config['recipients']
259 | send_mail(smtp_settings, recipients, subject, message)
260 | except:
261 | print('Sending notification for profile failed. Please check notifications config file.')
262 | pass
263 |
264 | def create_markdown_list_item_for_change(change):
265 | """
266 | Creates a markdown list item element for a configuration item with changed availability status
267 |
268 | Args:
269 | change (DataFrame): data for an individual configuration item containing information
270 | such as organization and product as well as current availability and availability difference
271 |
272 | Returns:
273 | str: html list item element
274 | """
275 | if change['availability_difference'] == 1:
276 | md_str = '🟢 '
277 | elif change['availability_difference'] == -1:
278 | md_str = '🔴 '
279 | else:
280 | md_str = '⚫️ '
281 | md_str += str(change['ci']) + ': ' + str(change['product']) + ', ' + str(change['name']) + ', ' + str(change['organization']) + ' '
282 | if change['availability_difference'] == 1:
283 | md_str += 'ist wieder verfügbar'
284 | elif change['availability_difference'] == -1:
285 | md_str += 'ist nicht mehr verfügbar'
286 | else:
287 | md_str += 'keine Veränderung'
288 | md_str += ', Stand: ' + str(pretty_timestamp(change['time'])) + '\n'
289 | return md_str
290 |
291 | def send_push_notifications(file_name, notifications_config_file, home_url, ntfy_url, ntfy_token):
292 | """
293 | Sends push notifications for each notification configuration about all
294 | changes that are relevant for the respective configuration
295 |
296 | Args:
297 | file_name (str): Path to hdf5 file
298 | notifications_config_file (str): Path to json file with notification configurations
299 | home_url (str): base url of dash app
300 | ntfy_url (str): base url for push notifications
301 | ntfy_token (str): access token for push notifications
302 |
303 | Returns:
304 | None
305 | """
306 | # get notification config
307 | with open(notifications_config_file, 'r', encoding='utf-8') as f:
308 | notification_config = json.load(f)
309 | # get changes
310 | ci_data = get_data_of_all_cis(file_name)
311 | changes = ci_data[ci_data['availability_difference']!=0]
312 | changes_sorted = changes.sort_values(by = 'availability_difference')
313 | # filter relevant changes for each config and send mails
314 | for config in notification_config:
315 | if (config['push_topic'] != ''):
316 | try:
317 | if (config['type'] == 'whitelist'):
318 | relevant_changes = changes_sorted[changes_sorted['ci'].isin(config['ci_list'])]
319 | elif (config['type'] == 'blacklist'):
320 | relevant_changes = changes_sorted[~changes_sorted['ci'].isin(config['ci_list'])]
321 | number_of_relevant_changes = len(relevant_changes)
322 | if number_of_relevant_changes > 0:
323 | message = ""
324 | for index, change in relevant_changes.iterrows():
325 | message += create_markdown_list_item_for_change(change)
326 | subject = str(number_of_relevant_changes) + ' Änderung(en) der Verfügbarkeit'
327 | ntfy_topic_url = ntfy_url + '/' + config['push_topic']
328 | requests.post(ntfy_topic_url,
329 | data=message.encode(encoding='utf-8'),
330 | headers={
331 | "title": subject.encode(encoding='utf-8'),
332 | "markdown": "yes",
333 | "authorization": "Bearer " + ntfy_token,
334 | "actions": f"view, Übersicht, {home_url}; view, Alerts, {ntfy_topic_url}; view, Fachportal, https://fachportal.gematik.de/ti-status".encode(encoding='utf-8')
335 | }
336 | )
337 | except:
338 | print('Sending push notification for profile failed. Please check notifications config file.')
339 | pass
340 |
341 | def main():
342 | return
343 |
344 | if __name__ == '__main__':
345 | main()
--------------------------------------------------------------------------------