Compare commits
354 Commits
v0.1.1
...
63-check-c
Author | SHA1 | Date | |
---|---|---|---|
27f81ab30e | |||
9ad4466d69 | |||
92f34459c3 | |||
60a1a351b1 | |||
3686fd84a8 | |||
10cbf49777 | |||
1eefd1b27a | |||
17f4dc3512 | |||
1dd601e8db | |||
b748603c06 | |||
d991f2a94d | |||
77df6f38ec | |||
542dc61811 | |||
2a28a4b3d4 | |||
41d0a38c1d | |||
8517ba749c | |||
9074212575 | |||
b27670535f | |||
b939b2bf57 | |||
c6b37fda69 | |||
8e3de6a220 | |||
c5c553f817 | |||
7d97365aea | |||
48b1e62e22 | |||
66cc818755 | |||
9d4786fae3 | |||
b78dc3519b | |||
46015c5b66 | |||
a4d0b73d2b | |||
9307fc0dad | |||
586d388e57 | |||
7ae34719fd | |||
033750eb10 | |||
befeef3153 | |||
98b971d7db | |||
56a63b276e | |||
c0ea5fcd51 | |||
d4fd54f51f | |||
d49671f0d4 | |||
84868dc22c | |||
4ad6c4bd0a | |||
9bdd8ba375 | |||
f027339425 | |||
3c5639708f | |||
639159c677 | |||
38261d6d2c | |||
47ebe46974 | |||
f347653be8 | |||
775a7df1ce | |||
f66b737f11 | |||
3a19212a78 | |||
280fbd445e | |||
91e2cf5ef1 | |||
262c54f2e5 | |||
b25e580113 | |||
ff27b7157d | |||
1e652a7856 | |||
74ff9f08a4 | |||
23cc2f365a | |||
53b4cf1a95 | |||
5ce13e962b | |||
3257493b6a | |||
2036077c06 | |||
8ce3aed870 | |||
998989d319 | |||
e95fa2fb12 | |||
1bcc8c13de | |||
2fc3299543 | |||
5575867632 | |||
46ba565c29 | |||
6cdbd35e64 | |||
d258d9081b | |||
eb49ba611b | |||
efa736f232 | |||
4a7030e85b | |||
464c8b8c1d | |||
3f1bb4f4e2 | |||
370ea87095 | |||
c8f6e6efc8 | |||
c949ec07e5 | |||
87658bfa58 | |||
99efd6c98a | |||
e42d11f125 | |||
6e0ec6b95a | |||
0ff56416dd | |||
3a2f6a2bb6 | |||
3eb9e68786 | |||
59403d1dba | |||
9f5ac664af | |||
5867ed9dd3 | |||
4d6d1879e6 | |||
2a34c0efc9 | |||
0ee00de5aa | |||
baeebdb9b8 | |||
8b194e7212 | |||
070100eba0 | |||
ce1489d9a1 | |||
ca1e73a0b5 | |||
041bf459ef | |||
c922e27758 | |||
4d5c0ce1fb | |||
bb0bbf5a28 | |||
1b4585d601 | |||
dad3ea80ee | |||
01446bdece | |||
43660a4dcb | |||
5320466b6c | |||
263cb02416 | |||
0b37fd7091 | |||
bdee969409 | |||
4c39920afd | |||
5e836c48b0 | |||
fb5a3c062c | |||
8fc0609aa4 | |||
a846a8765a | |||
8645becd82 | |||
011511d5ef | |||
e9839c2731 | |||
86bee9e2cf | |||
f419acb924 | |||
52171e8ebe | |||
a2124ba83d | |||
a046203339 | |||
b40d41ce8c | |||
57de96771c | |||
3bc148f7ea | |||
8e6b1ec799 | |||
8e5f5c73ec | |||
d4f984b138 | |||
24ebbf3b50 | |||
9c6bd64a7e | |||
6567aa803c | |||
e874350712 | |||
94d7b4c4f0 | |||
107429fda7 | |||
26b2f65e67 | |||
e863269a42 | |||
4ab95ef11f | |||
2244ef1b86 | |||
c3ddb387e2 | |||
ae5d8341cc | |||
40b2558943 | |||
9a6a0c6138 | |||
5985327219 | |||
06f9e8ace9 | |||
365a651918 | |||
5fcc24f915 | |||
3bd7239812 | |||
1672ad8640 | |||
710aeb1f18 | |||
06d11790b6 | |||
959f6889d4 | |||
0f5a68660d | |||
b809a2da02 | |||
effffcfc1a | |||
7b3151d227 | |||
26b415f336 | |||
bda3c30a74 | |||
8779600330 | |||
159fb46009 | |||
eabbbfbb68 | |||
4db38ef2f0 | |||
ed6d21e920 | |||
550bee5ad3 | |||
8313420de5 | |||
1651f446fe | |||
bd7dccbd87 | |||
8ae958b8c4 | |||
0f144568e3 | |||
08540e3bd7 | |||
43af1aa103 | |||
056a087065 | |||
a730ce2a53 | |||
12eb1feea6 | |||
af714f7b64 | |||
f47b0b7de4 | |||
d8ba6b67cb | |||
40b89dd4f1 | |||
e3aeee61de | |||
07e59f9b02 | |||
f751d64220 | |||
299bd56d63 | |||
a0c4d1863f | |||
fc1901211d | |||
bed91439db | |||
a8e008000e | |||
a9c771aa99 | |||
256d9d4ff0 | |||
41b87835ca | |||
3654962294 | |||
9382da7101 | |||
67ab0ef2be | |||
69d796dab4 | |||
4bfe7dc698 | |||
0aec5e4479 | |||
b1a83510a6 | |||
6806c4fd69 | |||
b2016df852 | |||
b332f3c5ff | |||
9eb65788e1 | |||
9392bcadc9 | |||
a008641192 | |||
5928d52237 | |||
1eb40b40c9 | |||
feb9f2430c | |||
200c5338ea | |||
5c15ad4518 | |||
0b6decf88d | |||
cfdf41d550 | |||
45c65d53ce | |||
4568f491f5 | |||
952ad8c0cf | |||
3e45bf8494 | |||
46ddaf10f7 | |||
408b121f26 | |||
61e5273158 | |||
50b8f7bbd4 | |||
25f286f73b | |||
50a6d66718 | |||
f5c80f6d81 | |||
7659939d3c | |||
f58d4a76cf | |||
c2dd450579 | |||
a1b62ad754 | |||
59d8744c84 | |||
d2a6ec17ea | |||
550403cc9f | |||
d3a4500568 | |||
2e4fee97a8 | |||
5355eee05c | |||
3e22000541 | |||
8c319197d0 | |||
a31d2b4bcc | |||
67d5fb4c67 | |||
329be65d1a | |||
91fe3d1c23 | |||
f4b86ce2ea | |||
19d0daa442 | |||
cc9811d11d | |||
8ce5b06823 | |||
3cc34fb30b | |||
17e04a3f89 | |||
f71a775e12 | |||
45c83e943b | |||
6dcbfde62e | |||
4cdc419478 | |||
90b529adb4 | |||
a3bc60986b | |||
f5df0b5d22 | |||
972ac745e9 | |||
358373cf70 | |||
27a62321fa | |||
30cf0fd22e | |||
531a8589db | |||
fa89a64ddd | |||
45ad5e8827 | |||
c4eb4d0fe2 | |||
4bc69a353c | |||
9d30f750f7 | |||
a1a252d5a9 | |||
568942bfe5 | |||
15f0432553 | |||
113bf2dd2e | |||
7ac151202a | |||
5d9d47c2df | |||
585468314c | |||
441bff3783 | |||
21959c1698 | |||
8a11e6e85b | |||
5579ad1453 | |||
c2026bdd07 | |||
de6faecb02 | |||
3be8bc53ff | |||
fad2f33fd6 | |||
d88e2973da | |||
af767e4ea6 | |||
f98c970348 | |||
75872a149f | |||
e24ba430a5 | |||
08914a6f86 | |||
104f50afcb | |||
0083e75940 | |||
c892ff2461 | |||
4a9cffbaa5 | |||
8a6f9a6e02 | |||
91f17f6af5 | |||
8d4497bf2c | |||
4ab20a5f16 | |||
167587a473 | |||
e5d80f89b0 | |||
5d0e815037 | |||
a5a19e0cea | |||
1493a63e02 | |||
fe927e65aa | |||
add09c3f9c | |||
5eb969c36a | |||
3cc4f8c1a4 | |||
707bc55ab6 | |||
d7949a7dce | |||
f5999ff325 | |||
a62da60809 | |||
ced6609d9a | |||
8dee349c37 | |||
3e45de56cf | |||
7f54efe034 | |||
effcdd811f | |||
acf49a892e | |||
284806d130 | |||
cf2d338e13 | |||
d5552b3ca4 | |||
892c0dea8f | |||
0305e69e9e | |||
1a913b2644 | |||
0eee1908df | |||
ffea9343c8 | |||
eb24995ed9 | |||
4196664060 | |||
2824951e5e | |||
1e1db1c4d9 | |||
7440fe1e23 | |||
3f5c5e28fa | |||
6397b2a019 | |||
bf8f87b261 | |||
2f32834de0 | |||
79709caa39 | |||
c52509054d | |||
8fd587c2a3 | |||
edafe30a4b | |||
e24be0d325 | |||
5e93e834ad | |||
5e5bd579fb | |||
a24f869c84 | |||
635985bfd1 | |||
25143745c4 | |||
532254593f | |||
01ff53ab23 | |||
9643c80cc5 | |||
aa40da4995 | |||
da26b5a2c8 | |||
bbea48322f | |||
480f165c7b | |||
3d2c73ff8f | |||
9921e1e684 | |||
5bd26b894c | |||
8dc82225a4 | |||
2eb5cc61b9 | |||
78b2287163 | |||
66dc96680d | |||
64b8636145 | |||
2e7ef25a49 | |||
7186a45f6c | |||
72295202ec | |||
bc48a7217e | |||
a075f73162 |
20
.github/workflows/deploy.yml
vendored
@ -1,4 +1,4 @@
|
||||
name: "Run build and deploy"
|
||||
name: 'Run build and deploy'
|
||||
|
||||
on:
|
||||
release:
|
||||
@ -8,20 +8,20 @@ jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-java@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '17'
|
||||
java-version: '21'
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/gradle-build-action@v2.4.2
|
||||
uses: gradle/actions/setup-gradle@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@ -30,6 +30,6 @@ jobs:
|
||||
- name: Execute image build and push
|
||||
run: |
|
||||
./gradlew bootBuildImage
|
||||
docker tag ghcr.io/ccc-mf/etl-processor ghcr.io/ccc-mf/etl-processor:${{ github.ref_name }}
|
||||
docker push ghcr.io/ccc-mf/etl-processor
|
||||
docker push ghcr.io/ccc-mf/etl-processor:${{ github.ref_name }}
|
||||
docker tag ghcr.io/${{ github.repository }} ghcr.io/${{ github.repository }}:${{ github.ref_name }}
|
||||
docker push ghcr.io/${{ github.repository }}
|
||||
docker push ghcr.io/${{ github.repository }}:${{ github.ref_name }}
|
16
.github/workflows/test.yml
vendored
@ -11,14 +11,14 @@ jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-java@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '17'
|
||||
java-version: '21'
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/gradle-build-action@v2.4.2
|
||||
uses: gradle/actions/setup-gradle@v3
|
||||
|
||||
- name: Execute tests
|
||||
run: ./gradlew test
|
||||
@ -26,14 +26,14 @@ jobs:
|
||||
integrationTests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-java@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '17'
|
||||
java-version: '21'
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/gradle-build-action@v2.4.2
|
||||
uses: gradle/actions/setup-gradle@v3
|
||||
|
||||
- name: Execute integration tests
|
||||
run: ./gradlew integrationTest
|
5
.gitignore
vendored
@ -5,6 +5,8 @@ build/
|
||||
!**/src/main/**/build/
|
||||
!**/src/test/**/build/
|
||||
|
||||
bindings/ca-certificates/*.pem
|
||||
|
||||
### STS ###
|
||||
.apt_generated
|
||||
.classpath
|
||||
@ -36,3 +38,6 @@ out/
|
||||
### VS Code ###
|
||||
.vscode/
|
||||
/dev/gpas*
|
||||
/deploy/.env
|
||||
/dev/gICS*
|
||||
/dev/gPAS*
|
||||
|
419
README.md
@ -1,50 +1,251 @@
|
||||
# ETL-Processor for bwHC data [](https://github.com/CCC-MF/etl-processor/actions/workflows/test.yml)
|
||||
# ETL-Processor for DNPM:DIP [](https://github.com/pcvolkmer/etl-processor/actions/workflows/test.yml)
|
||||
|
||||
Diese Anwendung versendet ein bwHC-MTB-File an das bwHC-Backend und pseudonymisiert die Patienten-ID.
|
||||
Diese Anwendung versendet ein bwHC-MTB-File im bwHC-Datenmodell 1.0 an DNPM:DIP und pseudonymisiert die Patienten-ID.
|
||||
|
||||
## Pseudonymisierung der Patienten-ID
|
||||
## Einordnung innerhalb einer DNPM-ETL-Strecke
|
||||
|
||||
Wenn eine URI zu einer gPAS-Instanz angegeben ist, wird diese verwendet.
|
||||
Diese Anwendung erlaubt das Entgegennehmen von HTTP/REST-Anfragen aus dem Onkostar-Plugin **[onkostar-plugin-dnpmexport](https://github.com/CCC-MF/onkostar-plugin-dnpmexport)**.
|
||||
|
||||
Der Inhalt einer Anfrage, wenn ein bwHC-MTBFile, wird pseudonymisiert und auf Duplikate geprüft.
|
||||
Duplikate werden verworfen, Änderungen werden weitergeleitet.
|
||||
|
||||
Löschanfragen werden immer als Löschanfrage an DNPM:DIP weitergeleitet.
|
||||
|
||||
Zudem ist eine minimalistische Weboberfläche integriert, die einen Einblick in den aktuellen Zustand der Anwendung gewährt.
|
||||
|
||||

|
||||
|
||||
### Duplikaterkennung
|
||||
|
||||
Die Erkennung von Duplikaten ist normalerweise immer aktiv, kann jedoch über den Konfigurationsparameter
|
||||
`APP_DUPLICATION_DETECTION=false` deaktiviert werden.
|
||||
|
||||
### Datenübermittlung über HTTP/REST
|
||||
|
||||
Anfragen werden, wenn nicht als Duplikat behandelt, nach der Pseudonymisierung direkt an DNPM:DIP gesendet.
|
||||
|
||||
Ein HTTP Request kann, angenommen die Installation erfolgte auf dem Host `dnpm.example.com` an nachfolgende URLs gesendet werden:
|
||||
|
||||
| HTTP-Request | URL | Consent-Status im Datensatz | Bemerkung |
|
||||
|--------------|-----------------------------------------|-----------------------------|---------------------------------------------------------------------------------|
|
||||
| `POST` | `https://dnpm.example.com/mtb` | `ACTIVE` | Die Anwendung verarbeitet den eingehenden Datensatz |
|
||||
| `POST` | `https://dnpm.example.com/mtb` | `REJECT` | Die Anwendung sendet einen Lösch-Request für die im Datensatz angegebene Pat-ID |
|
||||
| `DELETE` | `https://dnpm.example.com/mtb/12345678` | - | Die Anwendung sendet einen Lösch-Request für Pat-ID `12345678` |
|
||||
|
||||
Anstelle des Pfads `/mtb` kann auch, wie in Version 0.9 und älter üblich, `/mtbfile` verwendet werden.
|
||||
|
||||
### Datenübermittlung mit Apache Kafka
|
||||
|
||||
Anfragen werden, wenn nicht als Duplikat behandelt, nach der Pseudonymisierung an Apache Kafka übergeben.
|
||||
Eine Antwort wird dabei ebenfalls mithilfe von Apache Kafka übermittelt und nach der Entgegennahme verarbeitet.
|
||||
|
||||
Siehe hierzu auch: https://github.com/CCC-MF/kafka-to-bwhc
|
||||
|
||||
## Konfiguration
|
||||
|
||||
### 🔥 Wichtige Änderungen in Version 0.10
|
||||
|
||||
Ab Version 0.10 wird [DNPM:DIP](https://github.com/dnpm-dip) unterstützt und als Standardendpunkt verwendet.
|
||||
Soll noch das alte bwHC-Backend verwendet werden, so ist die Umgebungsvariable `APP_REST_IS_BWHC` auf `true` zu setzen.
|
||||
|
||||
### 🔥 Breaking Changes nach Version 0.10
|
||||
|
||||
In Versionen des ETL-Processors **nach Version 0.10** werden die folgenden Konfigurationsoptionen entfernt:
|
||||
|
||||
* `APP_KAFKA_TOPIC`: Nutzen Sie nun die Konfigurationsoption `APP_KAFKA_OUTPUT_TOPIC`
|
||||
* `APP_KAFKA_RESPONSE_TOPIC`: Nutzen Sie nun die Konfigurationsoption `APP_KAFKA_OUTPUT_RESPONSE_TOPIC`
|
||||
|
||||
Der Pfad zum Versenden von MTB-Daten ist nun offiziell `/mtb`.
|
||||
In Versionen **nach Version 0.10** wird die Unterstützung des Pfads `/mtbfile` entfernt.
|
||||
|
||||
### Pseudonymisierung der Patienten-ID
|
||||
|
||||
Wenn eine URI zu einer gPAS-Instanz (Version >= 2023.1.0) angegeben ist, wird diese verwendet.
|
||||
Ist diese nicht gesetzt. wird intern eine Anonymisierung der Patienten-ID vorgenommen.
|
||||
|
||||
* `APP_PSEUDONYMIZE_PREFIX`: Standortbezogenes Prefix - `UNKNOWN`, wenn nicht gesetzt
|
||||
* `APP_PSEUDONYMIZE_PREFIX`: Standortbezogenes Präfix - `UNKNOWN`, wenn nicht gesetzt
|
||||
* `APP_PSEUDONYMIZE_GENERATOR`: `BUILDIN` oder `GPAS` - `BUILDIN`, wenn nicht gesetzt
|
||||
|
||||
### Eingebaute Pseudonymisierung
|
||||
**Hinweis**
|
||||
|
||||
Wurde keine oder die Verwendung der eingebauten Pseudonymisierung konfiguriert, so wird für die Patienten-ID der
|
||||
entsprechende SHA-256-Hash gebildet und Base64-codiert - hier ohne endende "=" - zuzüglich des konfigurierten Prefixes
|
||||
Die Pseudonymisierung erfolgt im ETL-Prozessor nur für die Patienten-ID.
|
||||
Andere IDs werden mithilfe des standortbezogenen Präfixes (erneut) anonymisiert, um für den aktuellen Kontext nicht
|
||||
vergleichbare IDs bereitzustellen.
|
||||
|
||||
#### Eingebaute Anonymisierung
|
||||
|
||||
Wurde keine oder die Verwendung der eingebauten Anonymisierung konfiguriert, so wird für die Patienten-ID der
|
||||
entsprechende SHA-256-Hash gebildet und Base64-codiert - hier ohne endende "=" - zuzüglich des konfigurierten Präfixes
|
||||
als Patienten-Pseudonym verwendet.
|
||||
|
||||
### Pseudonymisierung mit gPAS
|
||||
#### Pseudonymisierung mit gPAS
|
||||
|
||||
Wurde die Verwendung von gPAS konfiguriert, so sind weitere Angaben zu konfigurieren.
|
||||
|
||||
* `APP_PSEUDONYMIZE_GPAS_URI`: URI der gPAS-Instanz inklusive Endpoint (z.B. `http://localhost:8080/ttp-fhir/fhir/gpas/$pseudonymizeAllowCreate`)
|
||||
* `APP_PSEUDONYMIZE_GPAS_URI`: URI der gPAS-Instanz inklusive Endpoint (z.B. `http://localhost:8080/ttp-fhir/fhir/gpas/$$pseudonymizeAllowCreate`)
|
||||
* `APP_PSEUDONYMIZE_GPAS_TARGET`: gPas Domänenname
|
||||
* `APP_PSEUDONYMIZE_GPAS_USERNAME`: gPas Basic-Auth Benutzername
|
||||
* `APP_PSEUDONYMIZE_GPAS_PASSWORD`: gPas Basic-Auth Passwort
|
||||
* `APP_PSEUDONYMIZE_GPAS_SSLCALOCATION`: Root Zertifikat für gPas, falls es dediziert hinzugefügt werden muss.
|
||||
|
||||
## Mögliche Endpunkte
|
||||
### Einwilligung gICS
|
||||
Ab gIcs Version 2.13.0 kann per [REST-Schnittstelle](https://simplifier.net/guide/ttp-fhir-gateway-ig/ImplementationGuide-markdown-Einwilligungsmanagement-Operations-isConsented?version=current) der Einwilligungsstatus abgefragt werden.
|
||||
Vor der MTB-Übertragung kann der zum Sendezeitpunkt verfügbarer Einwilligungsstatus über Endpunkt *isConsented* abgefragt werden.
|
||||
|
||||
Falls Anbindung an gICS aktiviert wurde, wird der Einwilligungsstatus der MTB Datei ignoriert.
|
||||
Stattdessen werden vorhandene Einwilligungen abgefragt und in die MTB Datei eingebettet.
|
||||
|
||||
Es werden zwei Einwilligungsdomänen unterstützt, eine für Broad Consent und als zweites GenomDE Modelvorhaben §64e.
|
||||
|
||||
#### Hinweise
|
||||
1. Die aktuelle Impl. nimmt an, dass die hinterlegten Domänen der Einwilligungen ausschließlich für die genannten Art von Einwilligungen genutzt werden. Es finde keine weitere Filterung statt. Wir fragen pro Domäne die Schnittstelle `CurrentPolicyStatesForPerson` - siehe auch [IG TTP-FHIR Gateway
|
||||
](https://www.ths-greifswald.de/wp-content/uploads/tools/fhirgw/ig/2024-3-0/ImplementationGuide-markdown-Einwilligungsmanagement-Operations-currentPolicyStatesForPerson.html) ab.
|
||||
2. Die Einwilligung wird für den Patienten-Identifier der MTB abgerufen und anschließend durch das DNPM Pseudonym ersetzt.
|
||||
3. Abfragen von Einwilligungen über gesonderte Pseudonyme anstatt des MTB-Identifiers fehlt in der ersten Implementierung.
|
||||
4. Bei Verarbeitung von MTB Version 1.x Inhalten ist eine positive Einwilligung für die
|
||||
Weiterverarbeitung notwendig. Das Fehlen einer Einwilligung löst die Löschung des Patienten im Brückenkopf aus.
|
||||
|
||||
#### Konfiguration
|
||||
* `APP_CONSENT_GICS_ENABLED`: Aktiviert oder deaktiviert `true` oder `false`, `false` wenn nicht gesetzt.
|
||||
* `APP_CONSENT_GICS_CHECKGNOMEDE`: Aktiviert oder deaktiviert `true` oder `false`, `false` wenn nicht gesetzt. Versuche Einwilligungsdaten zu GENOM DE Modelvorhaben über gIcs abzurufen.
|
||||
* `APP_CONSENT_GICS_CHECKBROADCONSENT`: Aktiviert oder deaktiviert `true` oder `false`, `false` wenn nicht gesetzt. Versuche Einwilligungsdaten zu Broad Consent über gIcs abzurufen.
|
||||
* `APP_CONSENT_GICS_URI`: URI der gICS-Instanz (z.B. `http://localhost:8090/ttp-fhir/fhir/gics`)
|
||||
* `APP_CONSENT_GICS_USERNAME`: gIcs Basic-Auth Benutzername
|
||||
* `APP_CONSENT_GICS_PASSWORD`: gIcs Basic-Auth Passwort
|
||||
* `APP_CONSENT_GICS_PERSONIDENTIFIERSYSTEM`: Derzeit wird nur die PID unterstützt. wenn leer wird `https://ths-greifswald.de/fhir/gics/identifiers/Patienten-ID` angenommen
|
||||
* `APP_CONSENT_GICS_BROADCONSENTDOMAINNAME`: Domäne in der gIcs Broad Consent Einwilligungen verwaltet. Falls Wert leer, wird `MII` angenommen.
|
||||
* `APP_CONSENT_GICS_GNOMDECONSENTDOMAINNAME`: Domäne in der gIcs GenomDE Modelvorhaben §64e Einwilligungen verwaltet. Falls Wert leer, wird `GenomDE_MV` angenommen.
|
||||
* `APP_CONSENT_GICS_POLICYCODE`: Die entscheidende Objekt-ID der zu prüfenden Einwilligung-Regel. Falls leer wird `2.16.840.1.113883.3.1937.777.24.5.3.6` angenommen.
|
||||
* `APP_CONSENT_GICS_POLICYSYSTEM`: Das System der Einwilligung-Regel der Objekt-IDs. Falls leer wird `urn:oid:2.16.840.1.113883.3.1937.777.24.5.3` angenommen.
|
||||
|
||||
### Anmeldung mit einem Passwort
|
||||
|
||||
Ein initialer Administrator-Account kann optional konfiguriert werden und sorgt dafür, dass bestimmte Bereiche nur nach
|
||||
einem erfolgreichen Login erreichbar sind.
|
||||
|
||||
* `APP_SECURITY_ADMIN_USER`: Muss angegeben werden zur Aktivierung der Zugriffsbeschränkung.
|
||||
* `APP_SECURITY_ADMIN_PASSWORD`: Das Passwort für den Administrator (Empfohlen).
|
||||
|
||||
Ein Administrator-Passwort muss inklusive des Encoding-Präfixes vorliegen.
|
||||
|
||||
Hier Beispiele für das Beispielpasswort `very-secret`:
|
||||
|
||||
* `{noop}very-secret` (Das Passwort liegt im Klartext vor - nicht empfohlen!)
|
||||
* `{bcrypt}$2y$05$CCkfsMr/wbTleMyjVIK8g.Aa3RCvrvoLXVAsL.f6KeouS88vXD9b6`
|
||||
* `{sha256}9a34717f0646b5e9cfcba70055de62edb026ff4f68671ba3db96aa29297d2df5f1a037d58c745657`
|
||||
|
||||
Wird kein Administrator-Passwort angegeben, wird ein zufälliger Wert generiert und beim Start der Anwendung in den Logs
|
||||
angezeigt.
|
||||
|
||||
#### Weitere (nicht administrative) Nutzer mit OpenID Connect
|
||||
|
||||
Die folgenden Konfigurationsparameter werden benötigt, um die Authentifizierung weiterer Benutzer an einen OIDC-Provider
|
||||
zu delegieren.
|
||||
Ein Admin-Benutzer muss dabei konfiguriert sein.
|
||||
|
||||
* `APP_SECURITY_ENABLE_OIDC`: Aktiviert die Nutzung von OpenID Connect. Damit sind weitere Parameter erforderlich
|
||||
* `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_CUSTOM_CLIENT_NAME`: Name. Wird beim zusätzlichen Loginbutton angezeigt.
|
||||
* `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_CUSTOM_CLIENT_ID`: Client-ID
|
||||
* `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_CUSTOM_CLIENT_SECRET`: Client-Secret
|
||||
* `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_CUSTOM_CLIENT_SCOPE[0]`: Hier sollte immer `openid` angegeben werden.
|
||||
* `SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_CUSTOM_ISSUER_URI`: Die URI des Providers,
|
||||
z.B. `https://auth.example.com/realm/example`
|
||||
* `SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_CUSTOM_USER_NAME_ATTRIBUTE`: Name des Attributes, welches den Benutzernamen
|
||||
enthält.
|
||||
Oft verwendet: `preferred_username`
|
||||
|
||||
Ist die Nutzung von OpenID Connect konfiguriert, erscheint ein zusätzlicher Login-Button zur Nutzung mit OpenID Connect
|
||||
und dem konfigurierten `CLIENT_NAME`.
|
||||
|
||||

|
||||
|
||||
Weitere Informationen zur Konfiguration des OIDC-Providers
|
||||
sind [hier](https://docs.spring.io/spring-security/reference/servlet/oauth2/index.html#oauth2-client)
|
||||
zu finden.
|
||||
|
||||
#### Rollenbasierte Berechtigungen
|
||||
|
||||
Wird OpenID Connect verwendet, gibt es eine rollenbasierte Berechtigungszuweisung.
|
||||
|
||||
Die Standardrolle für neue OIDC-Benutzer kann mit der Option `APP_SECURITY_DEFAULT_USER_ROLE` festgelegt werden.
|
||||
Mögliche Werte sind `user` oder `guest`. Standardwert ist `user`.
|
||||
|
||||
Benutzer mit der Rolle "Gast" sehen nur die Inhalte, die auch nicht angemeldete Benutzer sehen.
|
||||
|
||||
Hierdurch ist es möglich, einzelne Benutzer einzuschränken oder durch Änderung der Standardrolle auf `guest` nur
|
||||
einzelne Benutzer als vollwertige Nutzer zuzulassen.
|
||||
|
||||

|
||||
|
||||
Benutzer werden nach dem Entfernen oder der Änderung der vergebenen Rolle automatisch abgemeldet und müssen sich neu anmelden.
|
||||
Sie bekommen dabei wieder die Standardrolle zugewiesen.
|
||||
|
||||
#### Auswirkungen auf den dargestellten Inhalt
|
||||
|
||||
Nur Administratoren haben Zugriff auf den Konfigurationsbereich, nur angemeldete Benutzer können die anonymisierte oder
|
||||
pseudonymisierte Patienten-ID sowie den Qualitätsbericht von DNPM:DIP einsehen.
|
||||
|
||||
Wurde kein Administrator-Account konfiguriert, sind diese Inhalte generell nicht verfügbar.
|
||||
|
||||
### Tokenbasierte Authentifizierung für MTBFile-Endpunkt
|
||||
|
||||
Die Anwendung unterstützt das Erstellen und Nutzen einer tokenbasierten Authentifizierung für den MTB-File-Endpunkt.
|
||||
|
||||
Dies kann mit der Umgebungsvariable `APP_SECURITY_ENABLE_TOKENS` aktiviert (`true` oder `false`) werden
|
||||
und ist als Standardeinstellung nicht aktiv.
|
||||
|
||||
Ist diese Einstellung aktiviert worden, ist es Administratoren möglich, Zugriffstokens für Onkostar zu erstellen, die
|
||||
zur Nutzung des MTB-File-Endpunkts eine HTTP-Basic-Authentifizierung voraussetzen.
|
||||
|
||||

|
||||
|
||||
In diesem Fall kann der Endpunkt für das Onkostar-Plugin **[onkostar-plugin-dnpmexport](https://github.com/CCC-MF/onkostar-plugin-dnpmexport)** wie folgt konfiguriert werden:
|
||||
|
||||
```
|
||||
https://testonkostar:MTg1NTL...NGU4@etl.example.com/mtbfile
|
||||
```
|
||||
|
||||
Ist die Verwendung von Tokens aktiv, werden Anfragen ohne die Angabe der Token-Information abgelehnt.
|
||||
|
||||
Alternativ kann eine Authentifizierung über Benutzername/Passwort oder OIDC erfolgen.
|
||||
|
||||
### Transformation von Werten
|
||||
|
||||
In Onkostar kann es vorkommen, dass ein Wert eines Merkmalskatalogs an einem Standort angepasst wurde und dadurch nicht dem Wert entspricht,
|
||||
der von DNPM:DIP akzeptiert wird.
|
||||
|
||||
Diese Anwendung bietet daher die Möglichkeit, eine Transformation vorzunehmen. Hierzu muss der "Pfad" innerhalb des JSON-MTB-Files angegeben werden und
|
||||
welcher Wert wie ersetzt werden soll.
|
||||
|
||||
Hier ein Beispiel für die erste (Index 0 - weitere dann mit 1,2, ...) Transformationsregel:
|
||||
|
||||
* `APP_TRANSFORMATIONS_0_PATH`: Pfad zum Wert in der JSON-MTB-Datei. Beispiel: `diagnoses[*].icd10.version` für **alle** Diagnosen
|
||||
* `APP_TRANSFORMATIONS_0_FROM`: Angabe des Werts, der ersetzt werden soll. Andere Werte bleiben dabei unverändert.
|
||||
* `APP_TRANSFORMATIONS_0_TO`: Angabe des neuen Werts.
|
||||
|
||||
### Mögliche Endpunkte zur Datenübermittlung
|
||||
|
||||
Für REST-Requests als auch zur Nutzung von Kafka-Topics können Endpunkte konfiguriert werden.
|
||||
|
||||
Es ist dabei nur die Konfiguration eines Endpunkts zulässig.
|
||||
Werden sowohl REST als auch Kafka-Endpunkt konfiguriert, wird nur der REST-Endpunkt verwendet.
|
||||
|
||||
### REST
|
||||
#### REST
|
||||
|
||||
Folgende Umgebungsvariablen müssen gesetzt sein, damit ein bwHC-MTB-File an das bwHC-Backend gesendet wird:
|
||||
Folgende Umgebungsvariablen müssen gesetzt sein, damit ein bwHC-MTB-File an DNPM:DIP gesendet wird:
|
||||
|
||||
* `APP_REST_URI`: URI der zu benutzenden API der bwHC-Backend-Instanz. z.B.: `http://localhost:9000/bwhc/etl/api`
|
||||
* `APP_REST_URI`: URI der zu benutzenden API der Backend-Instanz. Zum Beispiel:
|
||||
* `http://localhost:9000/bwhc/etl/api` für **bwHC Backend**
|
||||
* `http://localhost:9000/api` für **dnpm:dip**
|
||||
* `APP_REST_USERNAME`: Basic-Auth-Benutzername für den REST-Endpunkt
|
||||
* `APP_REST_PASSWORD`: Basic-Auth-Passwort für den REST-Endpunkt
|
||||
* `APP_REST_IS_BWHC`: `true` für **bwHC Backend**, weglassen oder `false` für **dnpm:dip**
|
||||
|
||||
### Kafka-Topics
|
||||
#### Kafka-Topics
|
||||
|
||||
Folgende Umgebungsvariablen müssen gesetzt sein, damit ein bwHC-MTB-File an ein Kafka-Topic übermittelt wird:
|
||||
|
||||
* `APP_KAFKA_TOPIC`: Zu verwendendes Topic zum Versenden von Anfragen
|
||||
* `APP_KAFKA_RESPONSE_TOPIC`: Topic mit Antworten über den Erfolg des Versendens. Standardwert: `APP_KAFKA_TOPIC` mit Anhang "_response".
|
||||
* `APP_KAFKA_OUTPUT_TOPIC`: Zu verwendendes Topic zum Versenden von Anfragen.
|
||||
* `APP_KAFKA_OUTPUT_RESPONSE_TOPIC`: Topic mit Antworten über den Erfolg des Versendens. Standardwert: `APP_KAFKA_TOPIC` mit Anhang "_response".
|
||||
* `APP_KAFKA_GROUP_ID`: Kafka GroupID des Consumers. Standardwert: `APP_KAFKA_TOPIC` mit Anhang "_group".
|
||||
* `APP_KAFKA_SERVERS`: Zu verwendende Kafka-Bootstrap-Server als kommagetrennte Liste
|
||||
|
||||
@ -52,9 +253,187 @@ Wird keine Rückantwort über Apache Kafka empfangen und es gibt keine weitere M
|
||||
|
||||
Weitere Einstellungen können über die Parameter von Spring Kafka konfiguriert werden.
|
||||
|
||||
Lässt sich keine Verbindung zu dem bwHC-Backend aufbauen, wird eine Rückantwort mit Status-Code `900` erwartet, welchen es
|
||||
Lässt sich keine Verbindung zu dem Backend aufbauen, wird eine Rückantwort mit Status-Code `900` erwartet, welchen es
|
||||
für HTTP nicht gibt.
|
||||
|
||||
Wird die Umgebungsvariable `APP_KAFKA_INPUT_TOPIC` gesetzt, kann eine Nachricht auch über dieses Kafka-Topic an den ETL-Prozessor übermittelt werden.
|
||||
|
||||
##### Retention Time
|
||||
|
||||
Generell werden in Apache Kafka alle Records entsprechend der Konfiguration vorgehalten.
|
||||
So wird ohne spezielle Konfiguration ein Record für 7 Tage in Apache Kafka gespeichert.
|
||||
Es sind innerhalb dieses Zeitraums auch alte Informationen weiterhin enthalten, wenn der Consent später abgelehnt wurde.
|
||||
|
||||
Durch eine entsprechende Konfiguration des Topics kann dies verhindert werden.
|
||||
|
||||
Beispiel - auszuführen innerhalb des Kafka-Containers: Löschen alter Records nach einem Tag
|
||||
```
|
||||
kafka-configs.sh --bootstrap-server localhost:9092 --alter --topic test --add-config retention.ms=86400000
|
||||
```
|
||||
|
||||
##### Key based Retention
|
||||
|
||||
Möchten Sie hingegen immer nur die letzte Meldung für einen Patienten und eine Erkrankung in Apache Kafka vorhalten,
|
||||
so ist die nachfolgend genannte Konfiguration der Kafka-Topics hilfreich.
|
||||
|
||||
|
||||
* `retention.ms`: Möglichst kurze Zeit in der alte Records noch erhalten bleiben, z.B. 10 Sekunden 10000
|
||||
* `cleanup.policy`: Löschen alter Records und Beibehalten des letzten Records zu einem Key [delete,compact]
|
||||
|
||||
Beispiele für ein Topic `test`, hier bitte an die verwendeten Topics anpassen.
|
||||
|
||||
```
|
||||
kafka-configs.sh --bootstrap-server localhost:9092 --alter --topic test --add-config retention.ms=10000
|
||||
kafka-configs.sh --bootstrap-server localhost:9092 --alter --topic test --add-config cleanup.policy=[delete,compact]
|
||||
```
|
||||
|
||||
Da als Key eines Records die (pseudonymisierte) Patienten-ID verwendet wird, stehen mit obiger Konfiguration
|
||||
der Kafka-Topics nach 10 Sekunden nur noch der jeweils letzte Eintrag für den entsprechenden Key zur Verfügung.
|
||||
|
||||
Da der Key sowohl für die Records in Richtung DNPM:DIP, als auch für die Rückantwort identisch aufgebaut ist, lassen sich so
|
||||
auch im Falle eines Consent-Widerspruchs die enthaltenen Daten als auch die Offenlegung durch Verifikationsdaten in der
|
||||
Antwort effektiv verhindern, da diese nach 10 Sekunden gelöscht werden.
|
||||
|
||||
Es steht dann nur noch die jeweils letzten Information zur Verfügung, dass für einen Patienten/eine Erkrankung
|
||||
ein Consent-Widerspruch erfolgte.
|
||||
|
||||
Dieses Vorgehen empfiehlt sich, wenn Sie gespeicherte Records nachgelagert für andere Auswertungen verwenden möchten.
|
||||
|
||||
### Antworten und Statusauswertung
|
||||
|
||||
Anfragen an das bwHC-Backend aus Versionen bis 0.9.x wurden wie folgt behandelt:
|
||||
|
||||
| HTTP-Response | Status |
|
||||
|----------------|-----------|
|
||||
| `HTTP 200` | `SUCCESS` |
|
||||
| `HTTP 201` | `WARNING` |
|
||||
| `HTTP 400-...` | `ERROR` |
|
||||
|
||||
Dies konnte dazu führen, dass zwar mit einem `HTTP 201` geantwortet wurde, aber dennoch in der Issue-Liste die
|
||||
Severity `error` aufgetaucht ist.
|
||||
|
||||
Ab Version 0.10 wird die Issue-Liste der Antwort verwendet und die darion enthaltene höchste Severity-Stufe als Ergebnis verwendet.
|
||||
|
||||
| Höchste Severity | Status |
|
||||
|------------------|-----------|
|
||||
| `info` | `SUCCESS` |
|
||||
| `warning` | `WARNING` |
|
||||
| `error`, `fatal` | `ERROR` |
|
||||
|
||||
## Docker-Images
|
||||
|
||||
Diese Anwendung ist auch als Docker-Image verfügbar: https://github.com/CCC-MF/etl-processor/pkgs/container/etl-processor
|
||||
Diese Anwendung ist auch als Docker-Image verfügbar: https://github.com/pcvolkmer/etl-processor/pkgs/container/etl-processor
|
||||
|
||||
### Images lokal bauen
|
||||
|
||||
```bash
|
||||
./gradlew bootBuildImage
|
||||
```
|
||||
|
||||
### Integration eines eigenen Root CA Zertifikats
|
||||
|
||||
Wird eine eigene Root CA verwendet, die nicht offiziell signiert ist, wird es zu Problemen beim SSL-Handshake kommen, wenn z.B. gPAS zur Generierung von Pseudonymen verwendet wird.
|
||||
|
||||
Hier bietet es sich an, das Root CA Zertifikat in das Image zu integrieren.
|
||||
|
||||
#### Integration beim Bauen des Images
|
||||
|
||||
Hier muss die Zeile `"BP_EMBED_CERTS" to "true"` in der Datei `build.gradle.kts` verwendet werden und darf nicht als Kommentar verwendet werden.
|
||||
|
||||
Die PEM-Datei mit dem/den Root CA Zertifikat(en) muss dabei im vorbereiteten Verzeichnis [`bindings/ca-certificates`](bindings/ca-certificates) enthalten sein.
|
||||
|
||||
#### Integration zur Laufzeit
|
||||
|
||||
Hier muss die Umgebungsvariable `SERVICE_BINDING_ROOT` z.B. auf den Wert `/bindings` gesetzt sein.
|
||||
Zudem muss ein Verzeichnis `bindings/ca-certificates` - analog zum Verzeichnis [`bindings/ca-certificates`](bindings/ca-certificates) mit einer PEM-Datei als Docker-Volume eingebunden werden.
|
||||
|
||||
Beispiel für Docker-Compose:
|
||||
|
||||
```
|
||||
...
|
||||
environment:
|
||||
SERVICE_BINDING_ROOT: /bindings
|
||||
...
|
||||
volumes:
|
||||
- "/path/to/bindings/ca-certificates/:/bindings/ca-certificates/:ro"
|
||||
...
|
||||
```
|
||||
|
||||
## Deployment
|
||||
*Ausführen als Docker Container:*
|
||||
|
||||
```bash
|
||||
cd ./deploy
|
||||
cp env-sample.env .env
|
||||
```
|
||||
Wenn gewünscht, Änderungen in der `.env` vornehmen.
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Einfaches Beispiel für ein eigenes Docker-Compose-File
|
||||
|
||||
Die Datei [`docs/docker-compose.yml`](docs/docker-compose.yml) zeigt eine einfache Konfiguration für REST-Requests basierend
|
||||
auf Docker-Compose mit der gestartet werden kann.
|
||||
|
||||
### Betrieb hinter einem Reverse-Proxy
|
||||
|
||||
Die Anwendung verarbeitet `X-Forwarded`-HTTP-Header und kann daher auch hinter einem Reverse-Proxy betrieben werden.
|
||||
|
||||
Dabei werden, je nachdem welche Header durch den Reverse-Proxy gesendet werden auch Protokoll, Host oder auch Path-Präfix
|
||||
automatisch erkannt und verwendet werden. Dadurch ist z.B. eine abweichende Angabe des Pfads problemlos möglich.
|
||||
|
||||
#### Beispiel *Traefik* (mit Docker-Labels):
|
||||
|
||||
Das folgende Beispiel zeigt die Konfiguration in einer Docker-Compose-Datei mit Service-Labels.
|
||||
|
||||
```
|
||||
...
|
||||
deploy:
|
||||
labels:
|
||||
- "traefik.http.routers.etl.rule=PathPrefix(`/etl-processor`)"
|
||||
- "traefik.http.routers.etl.middlewares=etl-path-strip"
|
||||
- "traefik.http.middlewares.etl-path-strip.stripprefix.prefixes=/etl-processor"
|
||||
...
|
||||
```
|
||||
|
||||
#### Beispiel *nginx*
|
||||
|
||||
Das folgende Beispiel zeigt die Konfiguration einer _location_ in einer nginx-Konfigurationsdatei.
|
||||
|
||||
```
|
||||
...
|
||||
location /etl-processor {
|
||||
set $upstream http://<beispiel:8080>/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Scheme $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_pass $upstream;
|
||||
}
|
||||
...
|
||||
```
|
||||
|
||||
## Entwicklungssetup
|
||||
|
||||
Zum Starten einer lokalen Entwicklungs- und Testumgebung kann die beiliegende Datei `dev-compose.yml` verwendet werden.
|
||||
Diese kann zur Nutzung der Datenbanken **MariaDB** als auch **PostgreSQL** angepasst werden.
|
||||
|
||||
Zur Nutzung von Apache Kafka muss dazu ein Eintrag im hosts-File vorgenommen werden und der Hostname `kafka` auf die lokale
|
||||
IP-Adresse verweisen. Ohne diese Einstellung ist eine Nutzung von Apache Kafka außerhalb der Docker-Umgebung nicht möglich.
|
||||
|
||||
Beim Start der Anwendung mit dem Profil `dev` wird die in `dev-compose.yml` definierte Umgebung beim Start der
|
||||
Anwendung mit gestartet:
|
||||
|
||||
```
|
||||
SPRING_PROFILES_ACTIVE=dev ./gradlew bootRun
|
||||
```
|
||||
|
||||
Die Datei `application-dev.yml` enthält hierzu die Konfiguration für das Profil `dev`.
|
||||
|
||||
Beim Ausführen der Integrationstests wird eine Testdatenbank in einem Docker-Container gestartet.
|
||||
Siehe hier auch die Klasse `AbstractTestcontainerTest` unter `src/integrationTest`.
|
||||
|
||||
Ein einfaches Entwickler-Setup inklusive DNPM:DIP ist mit Hilfe von https://github.com/pcvolkmer/dnpmdip-devenv realisierbar.
|
||||
|
5
bindings/README.md
Normal file
@ -0,0 +1,5 @@
|
||||
# Hinweis für Root CA Zertifikate
|
||||
|
||||
PEM-Datei(en) in das Verzeichnis `ca-certificates` ablegen.
|
||||
|
||||
Die Datei `type` gibt dabei an, dass hier CA Zertifikate zu finden sind.
|
1
bindings/ca-certificates/type
Normal file
@ -0,0 +1 @@
|
||||
ca-certificates
|
104
build.gradle.kts
@ -1,20 +1,36 @@
|
||||
import org.gradle.api.tasks.testing.logging.TestLogEvent
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
||||
import org.springframework.boot.gradle.tasks.bundling.BootBuildImage
|
||||
|
||||
plugins {
|
||||
war
|
||||
id("org.springframework.boot") version "3.1.2"
|
||||
id("io.spring.dependency-management") version "1.1.0"
|
||||
kotlin("jvm") version "1.9.0"
|
||||
kotlin("plugin.spring") version "1.9.0"
|
||||
id("org.springframework.boot") version "3.5.0"
|
||||
id("io.spring.dependency-management") version "1.1.7"
|
||||
kotlin("jvm") version "1.9.25"
|
||||
kotlin("plugin.spring") version "1.9.25"
|
||||
jacoco
|
||||
}
|
||||
|
||||
group = "de.ukw.ccc"
|
||||
version = "0.1.1"
|
||||
group = "dev.dnpm"
|
||||
version = "0.11.0-SNAPSHOT"
|
||||
|
||||
var versions = mapOf(
|
||||
"bwhc-dto-java" to "0.4.0",
|
||||
"mtb-dto" to "0.1.0-SNAPSHOT",
|
||||
"hapi-fhir" to "7.6.0",
|
||||
"mockito-kotlin" to "5.4.0",
|
||||
"archunit" to "1.3.0",
|
||||
// Webjars
|
||||
"webjars-locator" to "0.52",
|
||||
"echarts" to "5.4.3",
|
||||
"htmx.org" to "1.9.12"
|
||||
)
|
||||
|
||||
java {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(21)
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
@ -33,9 +49,18 @@ configurations {
|
||||
compileOnly {
|
||||
extendsFrom(configurations.annotationProcessor.get())
|
||||
}
|
||||
|
||||
all {
|
||||
resolutionStrategy {
|
||||
cacheChangingModulesFor(5, "minutes")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
maven {
|
||||
url = uri("https://git.dnpm.dev/api/packages/public-snapshots/maven")
|
||||
}
|
||||
maven {
|
||||
url = uri("https://git.dnpm.dev/api/packages/public/maven")
|
||||
}
|
||||
@ -47,32 +72,51 @@ dependencies {
|
||||
implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
|
||||
implementation("org.springframework.boot:spring-boot-starter-web")
|
||||
implementation("org.springframework.boot:spring-boot-starter-data-jdbc")
|
||||
implementation("org.springframework.boot:spring-boot-starter-security")
|
||||
implementation("org.springframework.boot:spring-boot-starter-oauth2-client")
|
||||
implementation("org.thymeleaf.extras:thymeleaf-extras-springsecurity6")
|
||||
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
|
||||
implementation("org.springframework.kafka:spring-kafka")
|
||||
implementation("org.flywaydb:flyway-database-postgresql")
|
||||
implementation("org.flywaydb:flyway-mysql")
|
||||
implementation("commons-codec:commons-codec")
|
||||
implementation("io.projectreactor.kotlin:reactor-kotlin-extensions")
|
||||
implementation("de.ukw.ccc:bwhc-dto-java:0.2.0")
|
||||
implementation("ca.uhn.hapi.fhir:hapi-fhir-base:6.6.2")
|
||||
implementation("ca.uhn.hapi.fhir:hapi-fhir-structures-r4:6.6.2")
|
||||
implementation("org.apache.httpcomponents.client5:httpclient5:5.2.1")
|
||||
implementation("de.ukw.ccc:bwhc-dto-java:${versions["bwhc-dto-java"]}")
|
||||
implementation("dev.pcvolkmer.mv64e:mtb-dto:${versions["mtb-dto"]}") { isChanging = true }
|
||||
implementation("ca.uhn.hapi.fhir:hapi-fhir-base:${versions["hapi-fhir"]}")
|
||||
implementation("ca.uhn.hapi.fhir:hapi-fhir-structures-r4:${versions["hapi-fhir"]}")
|
||||
implementation("org.apache.httpcomponents.client5:httpclient5")
|
||||
implementation("com.jayway.jsonpath:json-path")
|
||||
implementation("org.webjars:webjars-locator:${versions["webjars-locator"]}")
|
||||
implementation("org.webjars.npm:echarts:${versions["echarts"]}")
|
||||
implementation("org.webjars.npm:htmx.org:${versions["htmx.org"]}")
|
||||
|
||||
runtimeOnly("org.mariadb.jdbc:mariadb-java-client")
|
||||
runtimeOnly("org.postgresql:postgresql")
|
||||
|
||||
developmentOnly("org.springframework.boot:spring-boot-devtools")
|
||||
developmentOnly("org.springframework.boot:spring-boot-docker-compose")
|
||||
|
||||
annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")
|
||||
|
||||
providedRuntime("org.springframework.boot:spring-boot-starter-tomcat")
|
||||
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
||||
testImplementation("org.springframework.security:spring-security-test")
|
||||
testImplementation("io.projectreactor:reactor-test")
|
||||
testImplementation("org.mockito.kotlin:mockito-kotlin:5.0.0")
|
||||
testImplementation("org.mockito.kotlin:mockito-kotlin:${versions["mockito-kotlin"]}")
|
||||
|
||||
integrationTestImplementation("org.testcontainers:junit-jupiter")
|
||||
integrationTestImplementation("org.testcontainers:postgresql")
|
||||
integrationTestImplementation("com.tngtech.archunit:archunit:${versions["archunit"]}")
|
||||
integrationTestImplementation("org.htmlunit:htmlunit")
|
||||
integrationTestImplementation("org.springframework:spring-webflux")
|
||||
}
|
||||
|
||||
tasks.withType<KotlinCompile> {
|
||||
kotlinOptions {
|
||||
freeCompilerArgs += "-Xjsr305=strict"
|
||||
jvmTarget = "17"
|
||||
compilerOptions {
|
||||
freeCompilerArgs.add("-Xjsr305=strict")
|
||||
jvmTarget.set(JvmTarget.JVM_21)
|
||||
}
|
||||
}
|
||||
|
||||
@ -83,8 +127,9 @@ tasks.withType<Test> {
|
||||
}
|
||||
}
|
||||
|
||||
task<Test>("integrationTest") {
|
||||
tasks.register<Test>("integrationTest") {
|
||||
description = "Runs integration tests"
|
||||
group = "verification"
|
||||
|
||||
testClassesDirs = sourceSets["integrationTest"].output.classesDirs
|
||||
classpath = sourceSets["integrationTest"].runtimeClasspath
|
||||
@ -92,11 +137,34 @@ task<Test>("integrationTest") {
|
||||
shouldRunAfter("test")
|
||||
}
|
||||
|
||||
tasks.register("allTests") {
|
||||
description = "Run all tests"
|
||||
group = JavaBasePlugin.VERIFICATION_GROUP
|
||||
dependsOn(tasks.withType<Test>())
|
||||
}
|
||||
|
||||
tasks.jacocoTestReport {
|
||||
dependsOn("allTests")
|
||||
|
||||
executionData(fileTree(project.rootDir.absolutePath).include("**/build/jacoco/*.exec"))
|
||||
|
||||
reports {
|
||||
xml.required = true
|
||||
}
|
||||
}
|
||||
|
||||
tasks.named<BootBuildImage>("bootBuildImage") {
|
||||
imageName.set("ghcr.io/ccc-mf/etl-processor")
|
||||
imageName.set("ghcr.io/pcvolkmer/etl-processor")
|
||||
|
||||
// Binding for CA Certs
|
||||
bindings.set(listOf(
|
||||
"$rootDir/bindings/ca-certificates/:/platform/bindings/ca-certificates"
|
||||
))
|
||||
|
||||
environment.set(environment.get() + mapOf(
|
||||
"BP_OCI_SOURCE" to "https://github.com/CCC-MF/etl-processor",
|
||||
// Enable this line to embed CA Certs into image on build time
|
||||
//"BP_EMBED_CERTS" to "true",
|
||||
"BP_OCI_SOURCE" to "https://github.com/pcvolkmer/etl-processor",
|
||||
"BP_OCI_LICENSES" to "AGPLv3",
|
||||
"BP_OCI_DESCRIPTION" to "ETL Processor for bwHC MTB files"
|
||||
))
|
||||
|
60
deploy/docker-compose.yaml
Normal file
@ -0,0 +1,60 @@
|
||||
|
||||
|
||||
services:
|
||||
dnpm-etl-processor:
|
||||
image: ghcr.io/ccc-mf/etl-processor:latest
|
||||
environment:
|
||||
LOGGING_LEVEL_DEV: ${DNPM_LOG_LEVEL:-INFO}
|
||||
SPRING_KAFKA_SECURITY_PROTOCOL: ${DNPM_KAFKA_SECURITY_PROTOCOL:-SSL}
|
||||
SPRING_KAFKA_SSL_TRUST-STORE-TYPE: PKCS12
|
||||
SPRING_KAFKA_SSL_TRUST-STORE-LOCATION: /opt/dnpm-processor/ssl/truststore.jks
|
||||
SPRING_KAFKA_SSL_TRUST-STORE-PASSWORD: ${KAFKA_TRUST_STORE_PASSWORD}
|
||||
SPRING_KAFKA_SSL_KEY-STORE-TYPE: PKCS12
|
||||
SPRING_KAFKA_SSL_KEY-STORE-LOCATION: /opt/dnpm-processor/ssl/keystore.jks
|
||||
SPRING_KAFKA_SSL_KEY-STORE-PASSWORD: ${DNPM_PROCESSOR_KEY_STORE_PASSWORD}
|
||||
SPRING_KAFKA_PRODUCER_COMPRESSION-TYPE: gzip
|
||||
APP_KAFKA_TOPIC: ${DNPM_KAFKA_TOPIC}
|
||||
APP_KAFKA_SERVERS: ${KAFKA_BROKERS}
|
||||
APP_KAFKA_GROUP_ID: ${DNPM_KAFKA_GROUP_ID}
|
||||
APP_KAFKA_RESPONSE_TOPIC: ${DNPM_KAFKA_RESPONSE_TOPIC}
|
||||
APP_REST_URI: ${DNPM_BWHC_REST_URI}
|
||||
APP_REST_USERNAME: ${DNPM_BWHC_REST_USERNAME}
|
||||
APP_REST_PASSWORD: ${DNPM_BWHC_REST_PASSWORD}
|
||||
APP_REST_IS_BWHC: ${DNPM_BWHC_REST_IS_BWHC}
|
||||
APP_SECURITY_ADMIN_USER: ${DNPM_ADMIN_USER}
|
||||
APP_SECURITY_ADMIN_PASSWORD: ${DNPM_ADMIN_PASSWORD}
|
||||
SPRING_DATASOURCE_URL: ${DNPM_DATASOURCE_URL}
|
||||
SPRING_DATASOURCE_PASSWORD: ${DNPM_MARIADB_USER_PW}
|
||||
SPRING_DATASOURCE_USERNAME: ${DNPM_MARIADB_DB}
|
||||
APP_PSEUDONYMIZE_GPAS_SSLCALOCATION: /workspace/opt/dnpm-processor/ssl/mosaic.crt
|
||||
APP_PSEUDONYMIZE_GPAS_PASSWORD: ${DNPM_PSEUDONYMIZE_GPAS_PASSWORD}
|
||||
APP_PSEUDONYMIZE_GPAS_USERNAME: ${DNPM_PSEUDONYMIZE_GPAS_USERNAME}
|
||||
APP_PSEUDONYMIZE_GPAS_TARGET: ${DNPM_PSEUDONYMIZE_GPAS_TARGET}
|
||||
APP_PSEUDONYMIZE_GPAS_URI: ${DNPM_PSEUDONYMIZE_GPAS_URI}
|
||||
APP_PSEUDONYMIZE_PREFIX: ${DNPM_APP_PSEUDONYMIZE_PREFIX}
|
||||
APP_PSEUDONYMIZER: ${DNPM_PSEUDONYMIZE_GENERATOR}
|
||||
volumes:
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- /etc/timezone:/etc/timezone:ro
|
||||
#- ${DNPM_TO_SSL_KEYSTORE_LOCATION}:/workspace/opt/dnpm-processor/ssl/keystore.jks:ro
|
||||
#- ${KAFKA_TRUST_STORE_LOCATION}:/workspace/opt/dnpm-processor/ssl/truststore.jks:ro
|
||||
#- ${DNPM_PSEUDONYMIZE_GPAS_SSLCALOCATION}:/workspace/opt/dnpm-processor/ssl/mosaic.crt
|
||||
|
||||
depends_on:
|
||||
- dnpm-monitor-db
|
||||
ports:
|
||||
- "${DNPM_MONITORING_HTTP_PORT:-8080}:8080"
|
||||
|
||||
# todo add volume
|
||||
dnpm-monitor-db:
|
||||
image: mariadb:10
|
||||
environment:
|
||||
MARIADB_DATABASE: ${DNPM_MARIADB_DB}
|
||||
MARIADB_USER: ${DNPM_MARIADB_USER}
|
||||
MARIADB_PASSWORD: ${DNPM_MARIADB_USER_PW}
|
||||
MARIADB_ROOT_PASSWORD: ${DNPM_MARIADB_ROOT_PW}
|
||||
expose:
|
||||
- "3306"
|
||||
|
||||
|
||||
|
47
deploy/env-sample.env
Normal file
@ -0,0 +1,47 @@
|
||||
# monitoring access port
|
||||
DNPM_MONITORING_HTTP_PORT=8088
|
||||
DNPM_LOG_LEVEL=INFO
|
||||
|
||||
# ADMIN USER CREDENTIALS
|
||||
DNPM_ADMIN_USER=admin
|
||||
DNPM_ADMIN_PASSWORD=
|
||||
|
||||
# GPAS or BUILDIN
|
||||
DNPM_PSEUDONYMIZE_GENERATOR=BUILDIN
|
||||
DNPM_APP_PSEUDONYMIZE_PREFIX=ANONYM
|
||||
DNPM_PSEUDONYMIZE_GPAS_URI=
|
||||
DNPM_PSEUDONYMIZE_GPAS_TARGET=
|
||||
DNPM_PSEUDONYMIZE_GPAS_USERNAME=
|
||||
DNPM_PSEUDONYMIZE_GPAS_PASSWORD=
|
||||
|
||||
# path to ca root cert if needed
|
||||
DNPM_PSEUDONYMIZE_GPAS_SSLCALOCATION=
|
||||
|
||||
DNPM_MARIADB_DB=dnpm_monitoring
|
||||
DNPM_MARIADB_USER=$DNPM_MARIADB_DB
|
||||
DNPM_MARIADB_USER_PW=MySuperSecurePassword111
|
||||
DNPM_MARIADB_ROOT_PW=MySuperDuperSecurePassword111
|
||||
|
||||
# monitoring data db
|
||||
DNPM_DATASOURCE_URL=jdbc:mariadb://dnpm-monitor-db:3306/$DNPM_MARIADB_DB
|
||||
|
||||
## TARGET SYSTEMS CONFIG
|
||||
# in case of direct access to bwhc enter endpoint url here
|
||||
DNPM_BWHC_REST_URI=
|
||||
DNPM_BWHC_REST_USERNAME=
|
||||
DNPM_BWHC_REST_PASSWORD=
|
||||
DNPM_BWHC_REST_IS_BWHC=false
|
||||
|
||||
# produce mtb files to this topic - values 'false' disabling kafka processing
|
||||
DNPM_KAFKA_TOPIC=false
|
||||
KAFKA_BROKERS=false
|
||||
DNPM_KAFKA_SECURITY_PROTOCOL=PLAINTEXT
|
||||
|
||||
# here we receive responses from bwhc
|
||||
DNPM_KAFKA_RESPONSE_TOPIC=dnpm-response
|
||||
DNPM_KAFKA_GROUP_ID=dnpm
|
||||
|
||||
# SSL or PLAINTEXT
|
||||
DNPM_PROCESSOR_KEY_STORE_PASSWORD=
|
||||
DNPM_TO_SSL_KEYSTORE_LOCATION=
|
||||
|
@ -4,9 +4,42 @@ services:
|
||||
hostname: kafka
|
||||
ports:
|
||||
- "9092:9092"
|
||||
- "9094:9094"
|
||||
environment:
|
||||
ALLOW_PLAINTEXT_LISTENER: "yes"
|
||||
KAFKA_CFG_NODE_ID: "0"
|
||||
KAFKA_CFG_PROCESS_ROLES: "controller,broker"
|
||||
KAFKA_CFG_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093,EXTERNAL://:9094
|
||||
KAFKA_CFG_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092,EXTERNAL://localhost:9094
|
||||
KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,EXTERNAL:PLAINTEXT,PLAINTEXT:PLAINTEXT
|
||||
KAFKA_CFG_INTER_BROKER_LISTENER_NAME: PLAINTEXT
|
||||
KAFKA_CFG_AUTO_CREATE_TOPICS_ENABLE: true
|
||||
KAFKA_CFG_CONTROLLER_QUORUM_VOTERS: 0@kafka:9093
|
||||
KAFKA_CFG_CONTROLLER_LISTENER_NAMES: CONTROLLER
|
||||
healthcheck:
|
||||
test: kafka-topics --bootstrap-server kafka:9092 --list
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
## Use AKHQ as Kafka web frontend
|
||||
akhq:
|
||||
image: tchiotludo/akhq:0.25.0
|
||||
environment:
|
||||
AKHQ_CONFIGURATION: |
|
||||
akhq:
|
||||
connections:
|
||||
docker-kafka-server:
|
||||
properties:
|
||||
bootstrap.servers: "kafka:9092"
|
||||
connect:
|
||||
- name: "kafka-connect"
|
||||
url: "http://kafka-connect:8083"
|
||||
ports:
|
||||
- "8084:8080"
|
||||
|
||||
|
||||
## For use with MariaDB
|
||||
mariadb:
|
||||
image: mariadb:10
|
||||
ports:
|
||||
@ -16,6 +49,8 @@ services:
|
||||
MARIADB_USER: dev
|
||||
MARIADB_PASSWORD: dev
|
||||
MARIADB_ROOT_PASSWORD: dev
|
||||
|
||||
## For use with Postgres
|
||||
# postgres:
|
||||
# image: postgres:alpine
|
||||
# ports:
|
||||
|
@ -2,31 +2,55 @@ version: '3.7'
|
||||
|
||||
services:
|
||||
|
||||
zoo1:
|
||||
image: zookeeper:3.8.0
|
||||
hostname: zoo1
|
||||
zoo:
|
||||
image: zookeeper:3.9.2
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "2181:2181"
|
||||
environment:
|
||||
ZOO_MY_ID: 1
|
||||
ZOO_PORT: 2181
|
||||
ZOO_SERVERS: server.1=zoo1:2888:3888;2181
|
||||
ZOO_SERVERS: server.1=zoo:2888:3888;2181
|
||||
|
||||
kafka1:
|
||||
image: confluentinc/cp-kafka:7.2.1
|
||||
hostname: kafka1
|
||||
kafka:
|
||||
image: confluentinc/cp-kafka:7.6.1
|
||||
ports:
|
||||
- "9092:9092"
|
||||
environment:
|
||||
KAFKA_ADVERTISED_LISTENERS: LISTENER_DOCKER_INTERNAL://kafka1:19092,LISTENER_DOCKER_EXTERNAL://${DOCKER_HOST_IP:-127.0.0.1}:9092
|
||||
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: LISTENER_DOCKER_INTERNAL:PLAINTEXT,LISTENER_DOCKER_EXTERNAL:PLAINTEXT
|
||||
KAFKA_ADVERTISED_LISTENERS: LISTENER_DOCKER_INTERNAL://kafka:19092,LISTENER_DOCKER_EXTERNAL://172.17.0.1:9093,LISTENER_EXTERNAL://127.0.0.1:9092
|
||||
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: LISTENER_DOCKER_INTERNAL:PLAINTEXT,LISTENER_DOCKER_EXTERNAL:PLAINTEXT,LISTENER_EXTERNAL:PLAINTEXT
|
||||
KAFKA_INTER_BROKER_LISTENER_NAME: LISTENER_DOCKER_INTERNAL
|
||||
KAFKA_ZOOKEEPER_CONNECT: "zoo1:2181"
|
||||
KAFKA_ZOOKEEPER_CONNECT: zoo:2181
|
||||
KAFKA_BROKER_ID: 1
|
||||
KAFKA_LOG4J_LOGGERS: "kafka.controller=INFO,kafka.producer.async.DefaultEventHandler=INFO,state.change.logger=INFO"
|
||||
KAFKA_LOG4J_LOGGERS: kafka.controller=INFO,kafka.producer.async.DefaultEventHandler=INFO,state.change.logger=INFO
|
||||
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
|
||||
KAFKA_MESSAGE_MAX_BYTES: 5242880
|
||||
KAFKA_REPLICA_FETCH_MAX_BYTES: 5242880
|
||||
KAFKA_COMPRESSION_TYPE: gzip
|
||||
depends_on:
|
||||
- zoo1
|
||||
- zoo
|
||||
healthcheck:
|
||||
test: kafka-topics --bootstrap-server kafka:9092 --list
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
akhq:
|
||||
image: tchiotludo/akhq:0.25.0
|
||||
environment:
|
||||
AKHQ_CONFIGURATION: |
|
||||
akhq:
|
||||
ui-options:
|
||||
topic.show-all-consumer-groups: true
|
||||
topic-data.sort: NEWEST
|
||||
connections:
|
||||
docker-kafka-server:
|
||||
properties:
|
||||
bootstrap.servers: "kafka:19092"
|
||||
ports:
|
||||
- "9000:8080"
|
||||
depends_on:
|
||||
- kafka
|
||||
|
||||
kafka-rest-proxy:
|
||||
image: confluentinc/cp-kafka-rest:7.2.1
|
||||
@ -40,8 +64,8 @@ services:
|
||||
KAFKA_REST_HOST_NAME: kafka-rest-proxy
|
||||
KAFKA_REST_BOOTSTRAP_SERVERS: PLAINTEXT://kafka1:19092
|
||||
depends_on:
|
||||
- zoo1
|
||||
- kafka1
|
||||
- zoo
|
||||
- kafka
|
||||
|
||||
kafka-connect:
|
||||
image: confluentinc/cp-kafka-connect:7.2.1
|
||||
@ -67,24 +91,6 @@ services:
|
||||
#volumes:
|
||||
# - ./connectors:/etc/kafka-connect/jars/
|
||||
depends_on:
|
||||
- zoo1
|
||||
- kafka1
|
||||
- zoo
|
||||
- kafka
|
||||
- kafka-rest-proxy
|
||||
|
||||
akhq:
|
||||
image: tchiotludo/akhq:0.21.0
|
||||
environment:
|
||||
AKHQ_CONFIGURATION: |
|
||||
akhq:
|
||||
connections:
|
||||
docker-kafka-server:
|
||||
properties:
|
||||
bootstrap.servers: "kafka1:19092"
|
||||
connect:
|
||||
- name: "kafka-connect"
|
||||
url: "http://kafka-connect:8083"
|
||||
ports:
|
||||
- "8084:8080"
|
||||
depends_on:
|
||||
- kafka1
|
||||
- kafka-connect
|
||||
|
28
docs/docker-compose.yml
Normal file
@ -0,0 +1,28 @@
|
||||
### Example for docker-compose
|
||||
version: '3.7'
|
||||
|
||||
volumes:
|
||||
data:
|
||||
|
||||
services:
|
||||
|
||||
### ETL-Processor
|
||||
etl-processor:
|
||||
image: ghcr.io/ccc-mf/etl-processor:latest
|
||||
ports:
|
||||
- 8080:8080
|
||||
environment:
|
||||
APP_REST_URI: http://bwhc-backend/bwhc/etl/api
|
||||
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres/etl
|
||||
SPRING_DATASOURCE_USERNAME: etl
|
||||
SPRING_DATASOURCE_PASSWORD: etl-password
|
||||
|
||||
### Database
|
||||
postgres:
|
||||
image: postgres:alpine
|
||||
environment:
|
||||
POSTGRES_DB: etl
|
||||
POSTGRES_USER: etl
|
||||
POSTGRES_PASSWORD: etl-password
|
||||
volumes:
|
||||
- data:/var/lib/postgresql/data
|
BIN
docs/etl.png
Normal file
After Width: | Height: | Size: 115 KiB |
BIN
docs/login.png
Normal file
After Width: | Height: | Size: 9.2 KiB |
BIN
docs/tokens.png
Normal file
After Width: | Height: | Size: 30 KiB |
BIN
docs/userroles.png
Normal file
After Width: | Height: | Size: 16 KiB |
2
gradle/wrapper/gradle-wrapper.properties
vendored
@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
|
||||
networkTimeout=10000
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
@ -19,22 +19,128 @@
|
||||
|
||||
package dev.dnpm.etl.processor
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import de.ukw.ccc.bwhc.dto.*
|
||||
import dev.dnpm.etl.processor.monitoring.RequestRepository
|
||||
import dev.dnpm.etl.processor.monitoring.RequestStatus
|
||||
import dev.dnpm.etl.processor.output.BwhcV1MtbFileRequest
|
||||
import dev.dnpm.etl.processor.output.MtbFileSender
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Nested
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.extension.ExtendWith
|
||||
import org.mockito.kotlin.*
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.boot.test.mock.mockito.MockBean
|
||||
import org.springframework.context.ApplicationContext
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.test.context.TestPropertySource
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean
|
||||
import org.springframework.test.context.junit.jupiter.SpringExtension
|
||||
import org.springframework.test.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.post
|
||||
import org.testcontainers.junit.jupiter.Testcontainers
|
||||
|
||||
@Testcontainers
|
||||
@ExtendWith(SpringExtension::class)
|
||||
@SpringBootTest
|
||||
@MockBean(MtbFileSender::class)
|
||||
@MockitoBean(types = [MtbFileSender::class])
|
||||
@TestPropertySource(
|
||||
properties = [
|
||||
"app.rest.uri=http://example.com",
|
||||
"app.pseudonymize.generator=buildin"
|
||||
]
|
||||
)
|
||||
class EtlProcessorApplicationTests : AbstractTestcontainerTest() {
|
||||
|
||||
@Test
|
||||
fun contextLoadsIfMtbFileSenderConfigured() {
|
||||
fun contextLoadsIfMtbFileSenderConfigured(@Autowired context: ApplicationContext) {
|
||||
// Simply check bean configuration
|
||||
assertThat(context).isNotNull
|
||||
}
|
||||
|
||||
@Nested
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
|
||||
@AutoConfigureMockMvc
|
||||
@TestPropertySource(
|
||||
properties = [
|
||||
"app.pseudonymize.generator=buildin",
|
||||
"app.transformations[0].path=diagnoses[*].icd10.version",
|
||||
"app.transformations[0].from=2013",
|
||||
"app.transformations[0].to=2014",
|
||||
]
|
||||
)
|
||||
inner class TransformationTest {
|
||||
|
||||
@MockitoBean
|
||||
private lateinit var mtbFileSender: MtbFileSender
|
||||
|
||||
@Autowired
|
||||
private lateinit var mockMvc: MockMvc
|
||||
|
||||
@Autowired
|
||||
private lateinit var objectMapper: ObjectMapper
|
||||
|
||||
@BeforeEach
|
||||
fun setup(@Autowired requestRepository: RequestRepository) {
|
||||
requestRepository.deleteAll()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mtbFileIsTransformed() {
|
||||
doAnswer {
|
||||
MtbFileSender.Response(RequestStatus.SUCCESS)
|
||||
}.whenever(mtbFileSender).send(any<BwhcV1MtbFileRequest>())
|
||||
|
||||
val mtbFile = MtbFile.builder()
|
||||
.withPatient(
|
||||
Patient.builder()
|
||||
.withId("TEST_12345678")
|
||||
.withBirthDate("2000-08-08")
|
||||
.withGender(Patient.Gender.MALE)
|
||||
.build()
|
||||
)
|
||||
.withConsent(
|
||||
Consent.builder()
|
||||
.withId("1")
|
||||
.withStatus(Consent.Status.ACTIVE)
|
||||
.withPatient("TEST_12345678")
|
||||
.build()
|
||||
)
|
||||
.withEpisode(
|
||||
Episode.builder()
|
||||
.withId("1")
|
||||
.withPatient("TEST_12345678")
|
||||
.withPeriod(PeriodStart("2023-08-08"))
|
||||
.build()
|
||||
)
|
||||
.withDiagnoses(
|
||||
listOf(
|
||||
Diagnosis.builder()
|
||||
.withId("1234")
|
||||
.withIcd10(Icd10.builder().withCode("F79.9").withVersion("2013").build())
|
||||
.build()
|
||||
)
|
||||
)
|
||||
.build()
|
||||
|
||||
mockMvc.post("/mtbfile") {
|
||||
content = objectMapper.writeValueAsString(mtbFile)
|
||||
contentType = MediaType.APPLICATION_JSON
|
||||
}.andExpect {
|
||||
status {
|
||||
isAccepted()
|
||||
}
|
||||
}
|
||||
|
||||
val captor = argumentCaptor<BwhcV1MtbFileRequest>()
|
||||
verify(mtbFileSender).send(captor.capture())
|
||||
assertThat(captor.firstValue.content.diagnoses).hasSize(1).allMatch { diagnosis ->
|
||||
diagnosis.icd10.version == "2014"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -0,0 +1,73 @@
|
||||
package dev.dnpm.etl.processor
|
||||
|
||||
import com.tngtech.archunit.core.domain.JavaClasses
|
||||
import com.tngtech.archunit.core.importer.ClassFileImporter
|
||||
import com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes
|
||||
import com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.data.repository.Repository
|
||||
|
||||
class EtlProcessorArchTest {
|
||||
|
||||
private lateinit var noTestClasses: JavaClasses
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
this.noTestClasses = ClassFileImporter()
|
||||
.withImportOption { !(it.contains("/test/") || it.contains("/integrationTest/")) }
|
||||
.importPackages("dev.dnpm.etl.processor")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noClassesInInputPackageShouldDependOnMonitoringPackage() {
|
||||
val rule = noClasses()
|
||||
.that()
|
||||
.resideInAPackage("..input")
|
||||
.should().dependOnClassesThat()
|
||||
.resideInAnyPackage("..monitoring")
|
||||
|
||||
rule.check(noTestClasses)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noClassesInInputPackageShouldDependOnRepositories() {
|
||||
val rule = noClasses()
|
||||
.that()
|
||||
.resideInAPackage("..input")
|
||||
.should().dependOnClassesThat().haveSimpleNameEndingWith("Repository")
|
||||
|
||||
rule.check(noTestClasses)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noClassesInOutputPackageShouldDependOnRepositories() {
|
||||
val rule = noClasses()
|
||||
.that()
|
||||
.resideInAPackage("..output")
|
||||
.should().dependOnClassesThat().haveSimpleNameEndingWith("Repository")
|
||||
|
||||
rule.check(noTestClasses)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noClassesInWebPackageShouldDependOnRepositories() {
|
||||
val rule = noClasses()
|
||||
.that()
|
||||
.resideInAPackage("..web")
|
||||
.should().dependOnClassesThat().haveSimpleNameEndingWith("Repository")
|
||||
|
||||
rule.check(noTestClasses)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun repositoryClassNamesShouldEndWithRepository() {
|
||||
val rule = classes()
|
||||
.that()
|
||||
.areInterfaces().and().areAssignableTo(Repository::class.java)
|
||||
.should().haveSimpleNameEndingWith("Repository")
|
||||
|
||||
rule.check(noTestClasses)
|
||||
}
|
||||
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
/*
|
||||
* This file is part of ETL-Processor
|
||||
*
|
||||
* Copyright (c) 2023 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
* Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
@ -20,9 +20,15 @@
|
||||
package dev.dnpm.etl.processor.config
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import dev.dnpm.etl.processor.input.KafkaInputListener
|
||||
import dev.dnpm.etl.processor.monitoring.RequestRepository
|
||||
import dev.dnpm.etl.processor.output.KafkaMtbFileSender
|
||||
import dev.dnpm.etl.processor.output.RestMtbFileSender
|
||||
import dev.dnpm.etl.processor.pseudonym.AnonymizingGenerator
|
||||
import dev.dnpm.etl.processor.pseudonym.GpasPseudonymGenerator
|
||||
import dev.dnpm.etl.processor.security.TokenRepository
|
||||
import dev.dnpm.etl.processor.security.TokenService
|
||||
import dev.dnpm.etl.processor.services.RequestProcessor
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.junit.jupiter.api.Nested
|
||||
import org.junit.jupiter.api.Test
|
||||
@ -30,14 +36,30 @@ import org.junit.jupiter.api.assertThrows
|
||||
import org.springframework.beans.factory.NoSuchBeanDefinitionException
|
||||
import org.springframework.boot.autoconfigure.kafka.KafkaAutoConfiguration
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.boot.test.mock.mockito.MockBean
|
||||
import org.springframework.boot.test.mock.mockito.MockBeans
|
||||
import org.springframework.context.ApplicationContext
|
||||
import org.springframework.retry.support.RetryTemplate
|
||||
import org.springframework.security.crypto.password.PasswordEncoder
|
||||
import org.springframework.security.provisioning.InMemoryUserDetailsManager
|
||||
import org.springframework.test.context.ContextConfiguration
|
||||
import org.springframework.test.context.TestPropertySource
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean
|
||||
|
||||
@SpringBootTest
|
||||
@ContextConfiguration(classes = [KafkaAutoConfiguration::class, AppKafkaConfiguration::class, AppRestConfiguration::class])
|
||||
@ContextConfiguration(
|
||||
classes = [
|
||||
AppConfiguration::class,
|
||||
AppSecurityConfiguration::class,
|
||||
KafkaAutoConfiguration::class,
|
||||
AppKafkaConfiguration::class,
|
||||
AppRestConfiguration::class
|
||||
]
|
||||
)
|
||||
@MockitoBean(types = [ObjectMapper::class])
|
||||
@TestPropertySource(
|
||||
properties = [
|
||||
"app.pseudonymize.generator=BUILDIN",
|
||||
]
|
||||
)
|
||||
class AppConfigurationTest {
|
||||
|
||||
@Nested
|
||||
@ -60,15 +82,12 @@ class AppConfigurationTest {
|
||||
@TestPropertySource(
|
||||
properties = [
|
||||
"app.kafka.servers=localhost:9092",
|
||||
"app.kafka.topic=test",
|
||||
"app.kafka.response-topic=test-response",
|
||||
"app.kafka.output-topic=test",
|
||||
"app.kafka.output-response-topic=test-response",
|
||||
"app.kafka.group-id=test"
|
||||
]
|
||||
)
|
||||
@MockBeans(value = [
|
||||
MockBean(ObjectMapper::class),
|
||||
MockBean(RequestRepository::class)
|
||||
])
|
||||
@MockitoBean(types = [RequestRepository::class])
|
||||
inner class AppConfigurationKafkaTest(private val context: ApplicationContext) {
|
||||
|
||||
@Test
|
||||
@ -84,8 +103,8 @@ class AppConfigurationTest {
|
||||
properties = [
|
||||
"app.rest.uri=http://localhost:9000",
|
||||
"app.kafka.servers=localhost:9092",
|
||||
"app.kafka.topic=test",
|
||||
"app.kafka.response-topic=test-response",
|
||||
"app.kafka.output-topic=test",
|
||||
"app.kafka.output-response-topic=test-response",
|
||||
"app.kafka.group-id=test"
|
||||
]
|
||||
)
|
||||
@ -99,4 +118,162 @@ class AppConfigurationTest {
|
||||
|
||||
}
|
||||
|
||||
@Nested
|
||||
@TestPropertySource(
|
||||
properties = [
|
||||
"app.kafka.servers=localhost:9092",
|
||||
"app.kafka.output-topic=test",
|
||||
"app.kafka.output-response-topic=test-response",
|
||||
"app.kafka.group-id=test"
|
||||
]
|
||||
)
|
||||
inner class AppConfigurationWithoutKafkaInputTest(private val context: ApplicationContext) {
|
||||
|
||||
@Test
|
||||
fun shouldNotUseKafkaInputListener() {
|
||||
assertThrows<NoSuchBeanDefinitionException> { context.getBean(KafkaInputListener::class.java) }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Nested
|
||||
@TestPropertySource(
|
||||
properties = [
|
||||
"app.kafka.servers=localhost:9092",
|
||||
"app.kafka.input-topic=test_input",
|
||||
"app.kafka.output-topic=test",
|
||||
"app.kafka.output-response-topic=test-response",
|
||||
"app.kafka.group-id=test"
|
||||
]
|
||||
)
|
||||
@MockitoBean(types = [RequestProcessor::class])
|
||||
inner class AppConfigurationUsingKafkaInputTest(private val context: ApplicationContext) {
|
||||
|
||||
@Test
|
||||
fun shouldUseKafkaInputListener() {
|
||||
assertThat(context.getBean(KafkaInputListener::class.java)).isNotNull
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Nested
|
||||
@TestPropertySource(
|
||||
properties = [
|
||||
"app.transformations[0].path=consent.status",
|
||||
"app.transformations[0].from=rejected",
|
||||
"app.transformations[0].to=accept",
|
||||
]
|
||||
)
|
||||
inner class AppConfigurationTransformationTest(private val context: ApplicationContext) {
|
||||
|
||||
@Test
|
||||
fun shouldRecognizeTransformations() {
|
||||
val appConfigProperties = context.getBean(AppConfigProperties::class.java)
|
||||
|
||||
assertThat(appConfigProperties).isNotNull
|
||||
assertThat(appConfigProperties.transformations).hasSize(1)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class AppConfigurationPseudonymizeTest {
|
||||
|
||||
@Nested
|
||||
@TestPropertySource(
|
||||
properties = [
|
||||
"app.pseudonymize.generator=buildin"
|
||||
]
|
||||
)
|
||||
inner class AppConfigurationPseudonymizeGeneratorBuildinTest(private val context: ApplicationContext) {
|
||||
|
||||
@Test
|
||||
fun shouldUseConfiguredGenerator() {
|
||||
assertThat(context.getBean(AnonymizingGenerator::class.java)).isNotNull
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Nested
|
||||
@TestPropertySource(
|
||||
properties = [
|
||||
"app.pseudonymize.generator=gpas"
|
||||
]
|
||||
)
|
||||
inner class AppConfigurationPseudonymizeGeneratorGpasTest(private val context: ApplicationContext) {
|
||||
|
||||
@Test
|
||||
fun shouldUseConfiguredGenerator() {
|
||||
assertThat(context.getBean(GpasPseudonymGenerator::class.java)).isNotNull
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Nested
|
||||
@TestPropertySource(
|
||||
properties = [
|
||||
"app.security.enable-tokens=true"
|
||||
]
|
||||
)
|
||||
@MockitoBean(
|
||||
types = [
|
||||
InMemoryUserDetailsManager::class,
|
||||
PasswordEncoder::class,
|
||||
TokenRepository::class
|
||||
]
|
||||
)
|
||||
inner class AppConfigurationTokenEnabledTest(private val context: ApplicationContext) {
|
||||
|
||||
@Test
|
||||
fun checkTokenService() {
|
||||
assertThat(context.getBean(TokenService::class.java)).isNotNull
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Nested
|
||||
@MockitoBean(
|
||||
types = [
|
||||
InMemoryUserDetailsManager::class,
|
||||
PasswordEncoder::class,
|
||||
TokenRepository::class
|
||||
]
|
||||
)
|
||||
inner class AppConfigurationTokenDisabledTest(private val context: ApplicationContext) {
|
||||
|
||||
@Test
|
||||
fun checkTokenService() {
|
||||
assertThrows<NoSuchBeanDefinitionException> { context.getBean(TokenService::class.java) }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Nested
|
||||
@TestPropertySource(
|
||||
properties = [
|
||||
"app.rest.uri=http://localhost:9000",
|
||||
"app.max-retry-attempts=5"
|
||||
]
|
||||
)
|
||||
inner class AppConfigurationRetryTest(private val context: ApplicationContext) {
|
||||
|
||||
private val maxRetryAttempts = 5
|
||||
|
||||
@Test
|
||||
fun shouldUseRetryTemplateWithConfiguredMaxAttempts() {
|
||||
val retryTemplate = context.getBean(RetryTemplate::class.java)
|
||||
assertThat(retryTemplate).isNotNull
|
||||
|
||||
assertThrows<RuntimeException> {
|
||||
retryTemplate.execute<Void, RuntimeException> {
|
||||
assertThat(it.retryCount).isLessThan(maxRetryAttempts)
|
||||
throw RuntimeException()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
30
src/integrationTest/kotlin/dev/dnpm/etl/processor/helpers.kt
Normal file
@ -0,0 +1,30 @@
|
||||
/*
|
||||
* This file is part of ETL-Processor
|
||||
*
|
||||
* Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.dnpm.etl.processor
|
||||
|
||||
import org.mockito.ArgumentMatchers
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
inline fun <reified T> anyValueClass(): T {
|
||||
val unboxedClass = T::class.java.declaredFields.first().type
|
||||
return ArgumentMatchers.any(unboxedClass as Class<T>)
|
||||
?: T::class.java.getDeclaredMethod("box-impl", unboxedClass)
|
||||
.invoke(null, null) as T
|
||||
}
|
@ -0,0 +1,228 @@
|
||||
/*
|
||||
* This file is part of ETL-Processor
|
||||
*
|
||||
* Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.dnpm.etl.processor.input
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import de.ukw.ccc.bwhc.dto.*
|
||||
import dev.dnpm.etl.processor.anyValueClass
|
||||
import dev.dnpm.etl.processor.config.AppSecurityConfiguration
|
||||
import dev.dnpm.etl.processor.consent.ConsentCheckFileBased
|
||||
import dev.dnpm.etl.processor.consent.TtpConsentStatus
|
||||
import dev.dnpm.etl.processor.consent.ICheckConsent
|
||||
import dev.dnpm.etl.processor.security.TokenRepository
|
||||
import dev.dnpm.etl.processor.security.UserRoleRepository
|
||||
import dev.dnpm.etl.processor.services.RequestProcessor
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Nested
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.extension.ExtendWith
|
||||
import org.mockito.junit.jupiter.MockitoExtension
|
||||
import org.mockito.kotlin.*
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository
|
||||
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous
|
||||
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
|
||||
import org.springframework.test.context.ContextConfiguration
|
||||
import org.springframework.test.context.TestPropertySource
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean
|
||||
import org.springframework.test.context.junit.jupiter.SpringExtension
|
||||
import org.springframework.test.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.delete
|
||||
import org.springframework.test.web.servlet.post
|
||||
|
||||
@WebMvcTest(controllers = [MtbFileRestController::class])
|
||||
@ExtendWith(value = [MockitoExtension::class, SpringExtension::class])
|
||||
@ContextConfiguration(
|
||||
classes = [
|
||||
MtbFileRestController::class,
|
||||
AppSecurityConfiguration::class,
|
||||
ConsentCheckFileBased::class, ICheckConsent::class
|
||||
]
|
||||
)
|
||||
@MockitoBean(types = [TokenRepository::class, RequestProcessor::class])
|
||||
@TestPropertySource(
|
||||
properties = [
|
||||
"app.pseudonymize.generator=BUILDIN",
|
||||
"app.security.admin-user=admin",
|
||||
"app.security.admin-password={noop}very-secret",
|
||||
"app.security.enable-tokens=true",
|
||||
"app.consent.gics.enabled=false"
|
||||
]
|
||||
)
|
||||
class MtbFileRestControllerTest {
|
||||
|
||||
private lateinit var mockMvc: MockMvc
|
||||
|
||||
private lateinit var requestProcessor: RequestProcessor
|
||||
|
||||
@BeforeEach
|
||||
fun setup(
|
||||
@Autowired mockMvc: MockMvc,
|
||||
@Autowired requestProcessor: RequestProcessor
|
||||
) {
|
||||
this.mockMvc = mockMvc
|
||||
this.requestProcessor = requestProcessor
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testShouldGrantPermissionToSendMtbFile() {
|
||||
mockMvc.post("/mtbfile") {
|
||||
with(user("onkostarserver").roles("MTBFILE"))
|
||||
contentType = MediaType.APPLICATION_JSON
|
||||
content = ObjectMapper().writeValueAsString(mtbFile)
|
||||
}.andExpect {
|
||||
status { isAccepted() }
|
||||
}
|
||||
|
||||
verify(requestProcessor, times(1)).processMtbFile(any<MtbFile>())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testShouldGrantPermissionToSendMtbFileToAdminUser() {
|
||||
mockMvc.post("/mtbfile") {
|
||||
with(user("onkostarserver").roles("ADMIN"))
|
||||
contentType = MediaType.APPLICATION_JSON
|
||||
content = ObjectMapper().writeValueAsString(mtbFile)
|
||||
}.andExpect {
|
||||
status { isAccepted() }
|
||||
}
|
||||
|
||||
verify(requestProcessor, times(1)).processMtbFile(any<MtbFile>())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testShouldDenyPermissionToSendMtbFile() {
|
||||
mockMvc.post("/mtbfile") {
|
||||
with(anonymous())
|
||||
contentType = MediaType.APPLICATION_JSON
|
||||
content = ObjectMapper().writeValueAsString(mtbFile)
|
||||
}.andExpect {
|
||||
status { isUnauthorized() }
|
||||
}
|
||||
|
||||
verify(requestProcessor, never()).processMtbFile(any<MtbFile>())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testShouldDenyPermissionToSendMtbFileForUser() {
|
||||
mockMvc.post("/mtbfile") {
|
||||
with(user("fakeuser").roles("USER"))
|
||||
contentType = MediaType.APPLICATION_JSON
|
||||
content = ObjectMapper().writeValueAsString(mtbFile)
|
||||
}.andExpect {
|
||||
status { isForbidden() }
|
||||
}
|
||||
|
||||
verify(requestProcessor, never()).processMtbFile(any<MtbFile>())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testShouldGrantPermissionToDeletePatientData() {
|
||||
mockMvc.delete("/mtbfile/12345678") {
|
||||
with(user("onkostarserver").roles("MTBFILE"))
|
||||
}.andExpect {
|
||||
status { isAccepted() }
|
||||
}
|
||||
|
||||
verify(requestProcessor, times(1)).processDeletion(anyValueClass(), eq(TtpConsentStatus.UNKNOWN_CHECK_FILE))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testShouldDenyPermissionToDeletePatientData() {
|
||||
mockMvc.delete("/mtbfile/12345678") {
|
||||
with(anonymous())
|
||||
}.andExpect {
|
||||
status { isUnauthorized() }
|
||||
}
|
||||
|
||||
verify(requestProcessor, never()).processDeletion(anyValueClass(), any())
|
||||
}
|
||||
|
||||
@Nested
|
||||
@MockitoBean(types = [UserRoleRepository::class, ClientRegistrationRepository::class])
|
||||
@TestPropertySource(
|
||||
properties = [
|
||||
"app.pseudonymize.generator=BUILDIN",
|
||||
"app.security.admin-user=admin",
|
||||
"app.security.admin-password={noop}very-secret",
|
||||
"app.security.enable-tokens=true",
|
||||
"app.security.enable-oidc=true",
|
||||
"app.consent.gics.enabled=false"
|
||||
]
|
||||
)
|
||||
inner class WithOidcEnabled {
|
||||
@Test
|
||||
fun testShouldGrantPermissionToSendMtbFileToAdminUser() {
|
||||
mockMvc.post("/mtbfile") {
|
||||
with(user("onkostarserver").roles("ADMIN"))
|
||||
contentType = MediaType.APPLICATION_JSON
|
||||
content = ObjectMapper().writeValueAsString(mtbFile)
|
||||
}.andExpect {
|
||||
status { isAccepted() }
|
||||
}
|
||||
|
||||
verify(requestProcessor, times(1)).processMtbFile(any<MtbFile>())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testShouldGrantPermissionToSendMtbFileToUser() {
|
||||
mockMvc.post("/mtbfile") {
|
||||
with(user("onkostarserver").roles("USER"))
|
||||
contentType = MediaType.APPLICATION_JSON
|
||||
content = ObjectMapper().writeValueAsString(mtbFile)
|
||||
}.andExpect {
|
||||
status { isAccepted() }
|
||||
}
|
||||
|
||||
verify(requestProcessor, times(1)).processMtbFile(any<MtbFile>())
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
val mtbFile: MtbFile = MtbFile.builder()
|
||||
.withPatient(
|
||||
Patient.builder()
|
||||
.withId("PID")
|
||||
.withBirthDate("2000-08-08")
|
||||
.withGender(Patient.Gender.MALE)
|
||||
.build()
|
||||
)
|
||||
.withConsent(
|
||||
Consent.builder()
|
||||
.withId("1")
|
||||
.withStatus(Consent.Status.ACTIVE)
|
||||
.withPatient("PID")
|
||||
.build()
|
||||
)
|
||||
.withEpisode(
|
||||
Episode.builder()
|
||||
.withId("1")
|
||||
.withPatient("PID")
|
||||
.withPeriod(PeriodStart("2023-08-08"))
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,75 @@
|
||||
/*
|
||||
* This file is part of ETL-Processor
|
||||
*
|
||||
* Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.dnpm.etl.processor.monitoring
|
||||
|
||||
import dev.dnpm.etl.processor.*
|
||||
import dev.dnpm.etl.processor.output.MtbFileSender
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.extension.ExtendWith
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.data.jdbc.DataJdbcTest
|
||||
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase
|
||||
import org.springframework.test.context.TestPropertySource
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean
|
||||
import org.springframework.test.context.junit.jupiter.SpringExtension
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import org.testcontainers.junit.jupiter.Testcontainers
|
||||
import java.time.Instant
|
||||
|
||||
@Testcontainers
|
||||
@ExtendWith(SpringExtension::class)
|
||||
@DataJdbcTest
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
@Transactional
|
||||
@MockitoBean(types = [MtbFileSender::class])
|
||||
@TestPropertySource(
|
||||
properties = [
|
||||
"app.pseudonymize.generator=buildin",
|
||||
"app.rest.uri=http://example.com"
|
||||
]
|
||||
)
|
||||
class RequestRepositoryTest : AbstractTestcontainerTest() {
|
||||
|
||||
private lateinit var requestRepository: RequestRepository
|
||||
|
||||
@BeforeEach
|
||||
fun setUp(
|
||||
@Autowired requestRepository: RequestRepository
|
||||
) {
|
||||
this.requestRepository = requestRepository
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldSaveRequest() {
|
||||
val request = Request(
|
||||
randomRequestId(),
|
||||
PatientPseudonym("TEST_12345678901"),
|
||||
PatientId("P1"),
|
||||
Fingerprint("0123456789abcdef1"),
|
||||
RequestType.MTB_FILE,
|
||||
RequestStatus.WARNING,
|
||||
Instant.parse("2023-07-07T00:00:00Z")
|
||||
)
|
||||
|
||||
requestRepository.save(request)
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,147 @@
|
||||
/*
|
||||
* This file is part of ETL-Processor
|
||||
*
|
||||
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.dnpm.etl.processor.pseudonym
|
||||
|
||||
import dev.dnpm.etl.processor.config.AppFhirConfig
|
||||
import dev.dnpm.etl.processor.config.GPasConfigProperties
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.assertThrows
|
||||
import org.springframework.http.HttpHeaders
|
||||
import org.springframework.http.HttpMethod
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.retry.policy.SimpleRetryPolicy
|
||||
import org.springframework.retry.support.RetryTemplateBuilder
|
||||
import org.springframework.test.web.client.MockRestServiceServer
|
||||
import org.springframework.test.web.client.match.MockRestRequestMatchers.method
|
||||
import org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo
|
||||
import org.springframework.test.web.client.response.MockRestResponseCreators.withException
|
||||
import org.springframework.test.web.client.response.MockRestResponseCreators.withStatus
|
||||
import org.springframework.web.client.RestTemplate
|
||||
import java.io.IOException
|
||||
|
||||
class GpasPseudonymGeneratorTest {
|
||||
|
||||
private lateinit var mockRestServiceServer: MockRestServiceServer
|
||||
private lateinit var generator: GpasPseudonymGenerator
|
||||
private lateinit var restTemplate: RestTemplate
|
||||
private var appFhirConfig: AppFhirConfig = AppFhirConfig()
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(1)).build()
|
||||
val gPasConfigProperties = GPasConfigProperties(
|
||||
"https://localhost/ttp-fhir/fhir/gpas/\$pseudonymizeAllowCreate",
|
||||
"test",
|
||||
null,
|
||||
null
|
||||
)
|
||||
|
||||
this.restTemplate = RestTemplate()
|
||||
this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate)
|
||||
this.generator =
|
||||
GpasPseudonymGenerator(gPasConfigProperties, retryTemplate, restTemplate, appFhirConfig)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldReturnExpectedPseudonym() {
|
||||
this.mockRestServiceServer.expect {
|
||||
method(HttpMethod.POST)
|
||||
requestTo("https://localhost/ttp-fhir/fhir/gpas/\$pseudonymizeAllowCreate")
|
||||
}.andRespond {
|
||||
withStatus(HttpStatus.OK).body(
|
||||
getDummyResponseBody(
|
||||
"1234",
|
||||
"test",
|
||||
"test1234ABCDEF567890"
|
||||
)
|
||||
)
|
||||
.createResponse(it)
|
||||
}
|
||||
|
||||
assertThat(this.generator.generate("ID1234")).isEqualTo("test1234ABCDEF567890")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldThrowExceptionIfGpasNotAvailable() {
|
||||
this.mockRestServiceServer.expect {
|
||||
method(HttpMethod.POST)
|
||||
requestTo("https://localhost/ttp-fhir/fhir/gpas/\$pseudonymizeAllowCreate")
|
||||
}.andRespond {
|
||||
withException(IOException("Simulated IO error")).createResponse(it)
|
||||
}
|
||||
|
||||
assertThrows<PseudonymRequestFailed> { this.generator.generate("ID1234") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldThrowExceptionIfGpasDoesNotReturn2xxResponse() {
|
||||
this.mockRestServiceServer.expect {
|
||||
method(HttpMethod.POST)
|
||||
requestTo("https://localhost/ttp-fhir/fhir/gpas/\$pseudonymizeAllowCreate")
|
||||
}.andRespond {
|
||||
withStatus(HttpStatus.FOUND)
|
||||
.header(
|
||||
HttpHeaders.LOCATION,
|
||||
"https://localhost/ttp-fhir/fhir/gpas/\$pseudonymizeAllowCreate"
|
||||
)
|
||||
.createResponse(it)
|
||||
}
|
||||
|
||||
assertThrows<PseudonymRequestFailed> { this.generator.generate("ID1234") }
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun getDummyResponseBody(original: String, target: String, pseudonym: String) = """{
|
||||
"resourceType": "Parameters",
|
||||
"parameter": [
|
||||
{
|
||||
"name": "pseudonym",
|
||||
"part": [
|
||||
{
|
||||
"name": "original",
|
||||
"valueIdentifier": {
|
||||
"system": "https://ths-greifswald.de/gpas",
|
||||
"value": "$original"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "target",
|
||||
"valueIdentifier": {
|
||||
"system": "https://ths-greifswald.de/gpas",
|
||||
"value": "$target"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "pseudonym",
|
||||
"valueIdentifier": {
|
||||
"system": "https://ths-greifswald.de/gpas",
|
||||
"value": "$pseudonym"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}""".trimIndent()
|
||||
|
||||
}
|
||||
}
|
@ -19,7 +19,7 @@
|
||||
|
||||
package dev.dnpm.etl.processor.services
|
||||
|
||||
import dev.dnpm.etl.processor.AbstractTestcontainerTest
|
||||
import dev.dnpm.etl.processor.*
|
||||
import dev.dnpm.etl.processor.monitoring.Request
|
||||
import dev.dnpm.etl.processor.monitoring.RequestRepository
|
||||
import dev.dnpm.etl.processor.monitoring.RequestStatus
|
||||
@ -31,18 +31,24 @@ import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.extension.ExtendWith
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.boot.test.mock.mockito.MockBean
|
||||
import org.springframework.test.context.TestPropertySource
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean
|
||||
import org.springframework.test.context.junit.jupiter.SpringExtension
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import org.testcontainers.junit.jupiter.Testcontainers
|
||||
import java.time.Instant
|
||||
import java.util.*
|
||||
|
||||
@Testcontainers
|
||||
@ExtendWith(SpringExtension::class)
|
||||
@SpringBootTest
|
||||
@Transactional
|
||||
@MockBean(MtbFileSender::class)
|
||||
@MockitoBean(types = [MtbFileSender::class])
|
||||
@TestPropertySource(
|
||||
properties = [
|
||||
"app.pseudonymize.generator=buildin",
|
||||
"app.rest.uri=http://example.com"
|
||||
]
|
||||
)
|
||||
class RequestServiceIntegrationTest : AbstractTestcontainerTest() {
|
||||
|
||||
private lateinit var requestRepository: RequestRepository
|
||||
@ -59,7 +65,7 @@ class RequestServiceIntegrationTest : AbstractTestcontainerTest() {
|
||||
|
||||
@Test
|
||||
fun shouldResultInEmptyRequestList() {
|
||||
val actual = requestService.allRequestsByPatientPseudonym("TEST_12345678901")
|
||||
val actual = requestService.allRequestsByPatientPseudonym(TEST_PATIENT_PSEUDONYM)
|
||||
|
||||
assertThat(actual).isEmpty()
|
||||
}
|
||||
@ -69,33 +75,33 @@ class RequestServiceIntegrationTest : AbstractTestcontainerTest() {
|
||||
this.requestRepository.saveAll(
|
||||
listOf(
|
||||
Request(
|
||||
uuid = UUID.randomUUID().toString(),
|
||||
patientId = "TEST_12345678901",
|
||||
pid = "P1",
|
||||
fingerprint = "0123456789abcdef1",
|
||||
type = RequestType.MTB_FILE,
|
||||
status = RequestStatus.SUCCESS,
|
||||
processedAt = Instant.parse("2023-07-07T02:00:00Z")
|
||||
randomRequestId(),
|
||||
PatientPseudonym("TEST_12345678901"),
|
||||
PatientId("P1"),
|
||||
Fingerprint("0123456789abcdef1"),
|
||||
RequestType.MTB_FILE,
|
||||
RequestStatus.SUCCESS,
|
||||
Instant.parse("2023-07-07T02:00:00Z")
|
||||
),
|
||||
// Should be ignored - wrong patient ID -->
|
||||
Request(
|
||||
uuid = UUID.randomUUID().toString(),
|
||||
patientId = "TEST_12345678902",
|
||||
pid = "P2",
|
||||
fingerprint = "0123456789abcdef2",
|
||||
type = RequestType.MTB_FILE,
|
||||
status = RequestStatus.WARNING,
|
||||
processedAt = Instant.parse("2023-08-08T00:00:00Z")
|
||||
randomRequestId(),
|
||||
PatientPseudonym("TEST_12345678902"),
|
||||
PatientId("P2"),
|
||||
Fingerprint("0123456789abcdef2"),
|
||||
RequestType.MTB_FILE,
|
||||
RequestStatus.WARNING,
|
||||
Instant.parse("2023-08-08T00:00:00Z")
|
||||
),
|
||||
// <--
|
||||
Request(
|
||||
uuid = UUID.randomUUID().toString(),
|
||||
patientId = "TEST_12345678901",
|
||||
pid = "P2",
|
||||
fingerprint = "0123456789abcdee1",
|
||||
type = RequestType.DELETE,
|
||||
status = RequestStatus.SUCCESS,
|
||||
processedAt = Instant.parse("2023-08-08T02:00:00Z")
|
||||
randomRequestId(),
|
||||
PatientPseudonym("TEST_12345678901"),
|
||||
PatientId("P2"),
|
||||
Fingerprint("0123456789abcdee1"),
|
||||
RequestType.DELETE,
|
||||
RequestStatus.SUCCESS,
|
||||
Instant.parse("2023-08-08T02:00:00Z")
|
||||
)
|
||||
)
|
||||
)
|
||||
@ -105,18 +111,18 @@ class RequestServiceIntegrationTest : AbstractTestcontainerTest() {
|
||||
fun shouldResultInSortedRequestList() {
|
||||
setupTestData()
|
||||
|
||||
val actual = requestService.allRequestsByPatientPseudonym("TEST_12345678901")
|
||||
val actual = requestService.allRequestsByPatientPseudonym(TEST_PATIENT_PSEUDONYM)
|
||||
|
||||
assertThat(actual).hasSize(2)
|
||||
assertThat(actual[0].fingerprint).isEqualTo("0123456789abcdee1")
|
||||
assertThat(actual[1].fingerprint).isEqualTo("0123456789abcdef1")
|
||||
assertThat(actual[0].fingerprint).isEqualTo(Fingerprint("0123456789abcdee1"))
|
||||
assertThat(actual[1].fingerprint).isEqualTo(Fingerprint("0123456789abcdef1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldReturnDeleteRequestAsLastRequest() {
|
||||
setupTestData()
|
||||
|
||||
val actual = requestService.isLastRequestWithKnownStatusDeletion("TEST_12345678901")
|
||||
val actual = requestService.isLastRequestWithKnownStatusDeletion(TEST_PATIENT_PSEUDONYM)
|
||||
|
||||
assertThat(actual).isTrue()
|
||||
}
|
||||
@ -125,10 +131,14 @@ class RequestServiceIntegrationTest : AbstractTestcontainerTest() {
|
||||
fun shouldReturnLastMtbFileRequest() {
|
||||
setupTestData()
|
||||
|
||||
val actual = requestService.lastMtbFileRequestForPatientPseudonym("TEST_12345678901")
|
||||
val actual = requestService.lastMtbFileRequestForPatientPseudonym(TEST_PATIENT_PSEUDONYM)
|
||||
|
||||
assertThat(actual).isNotNull
|
||||
assertThat(actual?.fingerprint).isEqualTo("0123456789abcdef1")
|
||||
assertThat(actual?.fingerprint).isEqualTo(Fingerprint("0123456789abcdef1"))
|
||||
}
|
||||
|
||||
companion object {
|
||||
val TEST_PATIENT_PSEUDONYM = PatientPseudonym("TEST_12345678901")
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,383 @@
|
||||
/*
|
||||
* This file is part of ETL-Processor
|
||||
*
|
||||
* Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.dnpm.etl.processor.web
|
||||
|
||||
import dev.dnpm.etl.processor.config.AppConfiguration
|
||||
import dev.dnpm.etl.processor.config.AppSecurityConfiguration
|
||||
import dev.dnpm.etl.processor.monitoring.ConnectionCheckResult
|
||||
import dev.dnpm.etl.processor.monitoring.GIcsConnectionCheckService
|
||||
import dev.dnpm.etl.processor.monitoring.GPasConnectionCheckService
|
||||
import dev.dnpm.etl.processor.monitoring.RestConnectionCheckService
|
||||
import dev.dnpm.etl.processor.output.MtbFileSender
|
||||
import dev.dnpm.etl.processor.pseudonym.Generator
|
||||
import dev.dnpm.etl.processor.security.Role
|
||||
import dev.dnpm.etl.processor.security.TokenService
|
||||
import dev.dnpm.etl.processor.security.UserRoleService
|
||||
import dev.dnpm.etl.processor.services.RequestProcessor
|
||||
import dev.dnpm.etl.processor.services.TransformationService
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.htmlunit.WebClient
|
||||
import org.htmlunit.html.HtmlPage
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Nested
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.extension.ExtendWith
|
||||
import org.mockito.ArgumentMatchers.anyString
|
||||
import org.mockito.junit.jupiter.MockitoExtension
|
||||
import org.mockito.kotlin.argumentCaptor
|
||||
import org.mockito.kotlin.times
|
||||
import org.mockito.kotlin.verify
|
||||
import org.mockito.kotlin.whenever
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
|
||||
import org.springframework.http.HttpHeaders
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.http.MediaType.TEXT_EVENT_STREAM
|
||||
import org.springframework.security.test.context.support.WithMockUser
|
||||
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous
|
||||
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
|
||||
import org.springframework.test.context.ContextConfiguration
|
||||
import org.springframework.test.context.TestPropertySource
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean
|
||||
import org.springframework.test.context.junit.jupiter.SpringExtension
|
||||
import org.springframework.test.web.reactive.server.WebTestClient
|
||||
import org.springframework.test.web.servlet.*
|
||||
import org.springframework.test.web.servlet.client.MockMvcWebTestClient
|
||||
import org.springframework.test.web.servlet.htmlunit.MockMvcWebClientBuilder
|
||||
import org.springframework.web.context.WebApplicationContext
|
||||
import reactor.core.publisher.Sinks
|
||||
import reactor.test.StepVerifier
|
||||
import java.time.Instant
|
||||
|
||||
abstract class MockSink : Sinks.Many<Boolean>
|
||||
|
||||
@WebMvcTest(controllers = [ConfigController::class])
|
||||
@ExtendWith(value = [MockitoExtension::class, SpringExtension::class])
|
||||
@ContextConfiguration(
|
||||
classes = [
|
||||
ConfigController::class,
|
||||
AppConfiguration::class,
|
||||
AppSecurityConfiguration::class
|
||||
]
|
||||
)
|
||||
@TestPropertySource(
|
||||
properties = [
|
||||
"app.pseudonymize.generator=BUILDIN"
|
||||
]
|
||||
)
|
||||
@MockitoBean(name = "configsUpdateProducer", types = [MockSink::class])
|
||||
@MockitoBean(
|
||||
types = [
|
||||
Generator::class,
|
||||
MtbFileSender::class,
|
||||
RequestProcessor::class,
|
||||
TransformationService::class,
|
||||
GPasConnectionCheckService::class,
|
||||
RestConnectionCheckService::class,
|
||||
GIcsConnectionCheckService::class
|
||||
]
|
||||
)
|
||||
class ConfigControllerTest {
|
||||
|
||||
private lateinit var mockMvc: MockMvc
|
||||
private lateinit var webClient: WebClient
|
||||
|
||||
private lateinit var requestProcessor: RequestProcessor
|
||||
private lateinit var connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult>
|
||||
|
||||
@BeforeEach
|
||||
fun setup(
|
||||
@Autowired mockMvc: MockMvc,
|
||||
@Autowired requestProcessor: RequestProcessor,
|
||||
@Autowired connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult>
|
||||
) {
|
||||
this.mockMvc = mockMvc
|
||||
this.webClient = MockMvcWebClientBuilder.mockMvcSetup(mockMvc).build()
|
||||
this.requestProcessor = requestProcessor
|
||||
this.connectionCheckUpdateProducer = connectionCheckUpdateProducer
|
||||
|
||||
webClient.options.isThrowExceptionOnScriptError = false
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testShouldRequestConfigPageIfLoggedIn() {
|
||||
mockMvc.get("/configs") {
|
||||
with(user("admin").roles("ADMIN"))
|
||||
accept(MediaType.TEXT_HTML)
|
||||
}.andExpect {
|
||||
status { isOk() }
|
||||
view { name("configs") }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testShouldRedirectToLoginPageIfNotLoggedIn() {
|
||||
mockMvc.get("/configs") {
|
||||
with(anonymous())
|
||||
accept(MediaType.TEXT_HTML)
|
||||
}.andExpect {
|
||||
status { isFound() }
|
||||
header {
|
||||
stringValues(HttpHeaders.LOCATION, "http://localhost/login")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@TestPropertySource(
|
||||
properties = [
|
||||
"app.security.enable-tokens=true",
|
||||
"app.security.admin-user=admin"
|
||||
]
|
||||
)
|
||||
@MockitoBean(
|
||||
types = [
|
||||
TokenService::class
|
||||
]
|
||||
)
|
||||
inner class WithTokensEnabled {
|
||||
private lateinit var tokenService: TokenService
|
||||
|
||||
@BeforeEach
|
||||
fun setup(
|
||||
@Autowired tokenService: TokenService
|
||||
) {
|
||||
webClient.options.isThrowExceptionOnScriptError = false
|
||||
|
||||
this.tokenService = tokenService
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testShouldSaveNewToken() {
|
||||
mockMvc.post("/configs/tokens") {
|
||||
with(user("admin").roles("ADMIN"))
|
||||
accept(MediaType.TEXT_HTML)
|
||||
contentType = MediaType.APPLICATION_FORM_URLENCODED
|
||||
content = "name=Testtoken"
|
||||
}.andExpect {
|
||||
status { is2xxSuccessful() }
|
||||
view { name("configs/tokens") }
|
||||
}
|
||||
|
||||
val captor = argumentCaptor<String>()
|
||||
verify(tokenService, times(1)).addToken(captor.capture())
|
||||
|
||||
assertThat(captor.firstValue).isEqualTo("Testtoken")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testShouldNotSaveTokenWithExstingName() {
|
||||
whenever(tokenService.addToken(anyString())).thenReturn(
|
||||
Result.failure(
|
||||
RuntimeException(
|
||||
"Testfailure"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
mockMvc.post("/configs/tokens") {
|
||||
with(user("admin").roles("ADMIN"))
|
||||
accept(MediaType.TEXT_HTML)
|
||||
contentType = MediaType.APPLICATION_FORM_URLENCODED
|
||||
content = "name=Testtoken"
|
||||
}.andExpect {
|
||||
status { is2xxSuccessful() }
|
||||
view { name("configs/tokens") }
|
||||
}
|
||||
|
||||
val captor = argumentCaptor<String>()
|
||||
verify(tokenService, times(1)).addToken(captor.capture())
|
||||
|
||||
assertThat(captor.firstValue).isEqualTo("Testtoken")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testShouldDeleteToken() {
|
||||
mockMvc.delete("/configs/tokens/42") {
|
||||
with(user("admin").roles("ADMIN"))
|
||||
accept(MediaType.TEXT_HTML)
|
||||
}.andExpect {
|
||||
status { is2xxSuccessful() }
|
||||
view { name("configs/tokens") }
|
||||
}
|
||||
|
||||
val captor = argumentCaptor<Long>()
|
||||
verify(tokenService, times(1)).deleteToken(captor.capture())
|
||||
|
||||
assertThat(captor.firstValue).isEqualTo(42)
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "admin", roles = ["ADMIN"])
|
||||
fun testShouldRenderConfigPageWithTokens() {
|
||||
val page = webClient.getPage<HtmlPage>("http://localhost/configs")
|
||||
assertThat(
|
||||
page.getElementById("tokens")
|
||||
).isNotNull
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@TestPropertySource(
|
||||
properties = [
|
||||
"app.security.enable-tokens=false"
|
||||
]
|
||||
)
|
||||
inner class WithTokensDisabled {
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
webClient.options.isThrowExceptionOnScriptError = false
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "admin", roles = ["ADMIN"])
|
||||
fun testShouldRenderConfigPageWithoutTokens() {
|
||||
val page = webClient.getPage<HtmlPage>("http://localhost/configs")
|
||||
assertThat(
|
||||
page.getElementById("tokens")
|
||||
).isNull()
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@TestPropertySource(
|
||||
properties = [
|
||||
"app.security.enable-tokens=false",
|
||||
"app.security.admin-user=admin",
|
||||
"app.security.admin-password={noop}very-secret"
|
||||
]
|
||||
)
|
||||
@MockitoBean(
|
||||
types = [
|
||||
UserRoleService::class
|
||||
]
|
||||
)
|
||||
inner class WithUserRolesEnabled {
|
||||
private lateinit var userRoleService: UserRoleService
|
||||
|
||||
@BeforeEach
|
||||
fun setup(
|
||||
@Autowired userRoleService: UserRoleService
|
||||
) {
|
||||
webClient.options.isThrowExceptionOnScriptError = false
|
||||
|
||||
this.userRoleService = userRoleService
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testShouldDeleteUserRole() {
|
||||
mockMvc.delete("/configs/userroles/42") {
|
||||
with(user("admin").roles("ADMIN"))
|
||||
accept(MediaType.TEXT_HTML)
|
||||
}.andExpect {
|
||||
status { is2xxSuccessful() }
|
||||
view { name("configs/userroles") }
|
||||
}
|
||||
|
||||
val captor = argumentCaptor<Long>()
|
||||
verify(userRoleService, times(1)).deleteUserRole(captor.capture())
|
||||
|
||||
assertThat(captor.firstValue).isEqualTo(42)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testShouldUpdateUserRole() {
|
||||
mockMvc.put("/configs/userroles/42") {
|
||||
with(user("admin").roles("ADMIN"))
|
||||
accept(MediaType.TEXT_HTML)
|
||||
contentType = MediaType.APPLICATION_FORM_URLENCODED
|
||||
content = "role=ADMIN"
|
||||
}.andExpect {
|
||||
status { is2xxSuccessful() }
|
||||
view { name("configs/userroles") }
|
||||
}
|
||||
|
||||
val idCaptor = argumentCaptor<Long>()
|
||||
val roleCaptor = argumentCaptor<Role>()
|
||||
verify(userRoleService, times(1)).updateUserRole(
|
||||
idCaptor.capture(),
|
||||
roleCaptor.capture()
|
||||
)
|
||||
|
||||
assertThat(idCaptor.firstValue).isEqualTo(42)
|
||||
assertThat(roleCaptor.firstValue).isEqualTo(Role.ADMIN)
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "admin", roles = ["ADMIN"])
|
||||
fun testShouldRenderConfigPageWithUserRoles() {
|
||||
val page = webClient.getPage<HtmlPage>("http://localhost/configs")
|
||||
assertThat(
|
||||
page.getElementById("userroles")
|
||||
).isNotNull
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class WithUserRolesDisabled {
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
webClient.options.isThrowExceptionOnScriptError = false
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testShouldRenderConfigPageWithoutUserRoles() {
|
||||
val page = webClient.getPage<HtmlPage>("http://localhost/configs")
|
||||
assertThat(
|
||||
page.getElementById("userroles")
|
||||
).isNull()
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class SseTest {
|
||||
private lateinit var webClient: WebTestClient
|
||||
|
||||
@BeforeEach
|
||||
fun setup(
|
||||
applicationContext: WebApplicationContext
|
||||
) {
|
||||
this.webClient = MockMvcWebTestClient
|
||||
.bindToApplicationContext(applicationContext).build()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testShouldRequestGPasSSE() {
|
||||
val expectedEvent =
|
||||
ConnectionCheckResult.GPasConnectionCheckResult(true, Instant.now(), Instant.now())
|
||||
|
||||
connectionCheckUpdateProducer.tryEmitNext(expectedEvent)
|
||||
connectionCheckUpdateProducer.emitComplete { _, _ -> true }
|
||||
|
||||
val result =
|
||||
webClient.get().uri("http://localhost/configs/events").accept(TEXT_EVENT_STREAM)
|
||||
.exchange()
|
||||
.expectStatus().isOk()
|
||||
.expectHeader().contentType(TEXT_EVENT_STREAM)
|
||||
.returnResult(ConnectionCheckResult.GPasConnectionCheckResult::class.java)
|
||||
|
||||
StepVerifier.create(result.responseBody)
|
||||
.expectNext(expectedEvent)
|
||||
.expectComplete()
|
||||
.verify()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,287 @@
|
||||
/*
|
||||
* This file is part of ETL-Processor
|
||||
*
|
||||
* Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.dnpm.etl.processor.web
|
||||
|
||||
import dev.dnpm.etl.processor.*
|
||||
import dev.dnpm.etl.processor.config.AppConfiguration
|
||||
import dev.dnpm.etl.processor.config.AppSecurityConfiguration
|
||||
import dev.dnpm.etl.processor.monitoring.Report
|
||||
import dev.dnpm.etl.processor.monitoring.Request
|
||||
import dev.dnpm.etl.processor.monitoring.RequestStatus
|
||||
import dev.dnpm.etl.processor.monitoring.RequestType
|
||||
import dev.dnpm.etl.processor.services.RequestService
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.htmlunit.WebClient
|
||||
import org.htmlunit.html.HtmlPage
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Nested
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.assertThrows
|
||||
import org.junit.jupiter.api.extension.ExtendWith
|
||||
import org.mockito.junit.jupiter.MockitoExtension
|
||||
import org.mockito.kotlin.any
|
||||
import org.mockito.kotlin.whenever
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
|
||||
import org.springframework.data.domain.Page
|
||||
import org.springframework.data.domain.PageImpl
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.security.test.context.support.WithMockUser
|
||||
import org.springframework.test.context.ContextConfiguration
|
||||
import org.springframework.test.context.TestPropertySource
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean
|
||||
import org.springframework.test.context.junit.jupiter.SpringExtension
|
||||
import org.springframework.test.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.get
|
||||
import org.springframework.test.web.servlet.htmlunit.MockMvcWebClientBuilder
|
||||
import java.io.IOException
|
||||
import java.time.Instant
|
||||
import java.util.*
|
||||
|
||||
@WebMvcTest(controllers = [HomeController::class])
|
||||
@ExtendWith(value = [MockitoExtension::class, SpringExtension::class])
|
||||
@ContextConfiguration(
|
||||
classes = [
|
||||
HomeController::class,
|
||||
AppConfiguration::class,
|
||||
AppSecurityConfiguration::class
|
||||
]
|
||||
)
|
||||
@TestPropertySource(
|
||||
properties = [
|
||||
"app.pseudonymize.generator=BUILDIN",
|
||||
"app.security.admin-user=admin",
|
||||
"app.security.admin-password={noop}very-secret"
|
||||
]
|
||||
)
|
||||
@MockitoBean(
|
||||
types = [RequestService::class]
|
||||
)
|
||||
class HomeControllerTest {
|
||||
|
||||
private lateinit var mockMvc: MockMvc
|
||||
private lateinit var webClient: WebClient
|
||||
|
||||
@BeforeEach
|
||||
fun setup(
|
||||
@Autowired mockMvc: MockMvc,
|
||||
@Autowired requestService: RequestService
|
||||
) {
|
||||
this.mockMvc = mockMvc
|
||||
this.webClient = MockMvcWebClientBuilder.mockMvcSetup(mockMvc).build()
|
||||
|
||||
whenever(requestService.findAll(any<Pageable>())).thenReturn(Page.empty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testShouldRequestHomePage() {
|
||||
mockMvc.get("/").andExpect {
|
||||
status { isOk() }
|
||||
view { name("index") }
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class WithRequests {
|
||||
|
||||
private lateinit var requestService: RequestService
|
||||
|
||||
@BeforeEach
|
||||
fun setup(
|
||||
@Autowired requestService: RequestService
|
||||
) {
|
||||
this.requestService = requestService
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testShouldShowHomePage() {
|
||||
whenever(requestService.findAll(any<Pageable>())).thenReturn(
|
||||
PageImpl(
|
||||
listOf(
|
||||
Request(
|
||||
2L,
|
||||
randomRequestId(),
|
||||
PatientPseudonym("PSEUDO1"),
|
||||
PatientId("PATIENT1"),
|
||||
Fingerprint("ashdkasdh"),
|
||||
RequestType.MTB_FILE,
|
||||
RequestStatus.SUCCESS
|
||||
),
|
||||
Request(
|
||||
1L,
|
||||
randomRequestId(),
|
||||
PatientPseudonym("PSEUDO1"),
|
||||
PatientId("PATIENT1"),
|
||||
Fingerprint("asdasdasd"),
|
||||
RequestType.MTB_FILE,
|
||||
RequestStatus.ERROR
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val page = webClient.getPage<HtmlPage>("http://localhost/")
|
||||
assertThat(page.querySelectorAll("tbody tr")).hasSize(2)
|
||||
assertThat(page.querySelectorAll("div.notification.info")).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "admin", roles = ["ADMIN"])
|
||||
fun testShouldShowRequestDetails() {
|
||||
val requestId = randomRequestId()
|
||||
|
||||
whenever(requestService.findByUuid(anyValueClass())).thenReturn(
|
||||
Optional.of(
|
||||
Request(
|
||||
2L,
|
||||
requestId,
|
||||
PatientPseudonym("PSEUDO1"),
|
||||
PatientId("PATIENT1"),
|
||||
Fingerprint("ashdkasdh"),
|
||||
RequestType.MTB_FILE,
|
||||
RequestStatus.SUCCESS,
|
||||
Instant.now(),
|
||||
Report("Test")
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val page = webClient.getPage<HtmlPage>("http://localhost/report/${requestId.value}")
|
||||
assertThat(page.querySelectorAll("tbody tr")).hasSize(1)
|
||||
assertThat(page.querySelectorAll("div.notification.info")).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "admin", roles = ["ADMIN"])
|
||||
fun testShouldShowPatientDetails() {
|
||||
whenever(requestService.findRequestByPatientId(anyValueClass(), any<Pageable>())).thenReturn(
|
||||
PageImpl(
|
||||
listOf(
|
||||
Request(
|
||||
2L,
|
||||
randomRequestId(),
|
||||
PatientPseudonym("PSEUDO1"),
|
||||
PatientId("PATIENT1"),
|
||||
Fingerprint("ashdkasdh"),
|
||||
RequestType.MTB_FILE,
|
||||
RequestStatus.SUCCESS
|
||||
),
|
||||
Request(
|
||||
1L,
|
||||
randomRequestId(),
|
||||
PatientPseudonym("PSEUDO1"),
|
||||
PatientId("PATIENT1"),
|
||||
Fingerprint("asdasdasd"),
|
||||
RequestType.MTB_FILE,
|
||||
RequestStatus.ERROR
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val page = webClient.getPage<HtmlPage>("http://localhost/patient/PSEUDO1")
|
||||
assertThat(page.querySelectorAll("tbody tr")).hasSize(2)
|
||||
assertThat(page.querySelectorAll("div.notification.info")).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "admin", roles = ["ADMIN"])
|
||||
fun testShouldShowPatientPseudonym() {
|
||||
whenever(requestService.findRequestByPatientId(anyValueClass(), any<Pageable>())).thenReturn(
|
||||
PageImpl(
|
||||
listOf(
|
||||
Request(
|
||||
2L,
|
||||
randomRequestId(),
|
||||
PatientPseudonym("PSEUDO1"),
|
||||
PatientId("PATIENT1"),
|
||||
Fingerprint("ashdkasdh"),
|
||||
RequestType.MTB_FILE,
|
||||
RequestStatus.SUCCESS
|
||||
),
|
||||
Request(
|
||||
1L,
|
||||
randomRequestId(),
|
||||
PatientPseudonym("PSEUDO1"),
|
||||
PatientId("PATIENT1"),
|
||||
Fingerprint("asdasdasd"),
|
||||
RequestType.MTB_FILE,
|
||||
RequestStatus.ERROR
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val page = webClient.getPage<HtmlPage>("http://localhost/patient/PSEUDO1")
|
||||
assertThat(page.querySelectorAll("h2 > span")).hasSize(1)
|
||||
assertThat(page.querySelectorAll("h2 > span").first().textContent).isEqualTo("PSEUDO1")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class WithoutRequests {
|
||||
|
||||
private lateinit var requestService: RequestService
|
||||
|
||||
@BeforeEach
|
||||
fun setup(
|
||||
@Autowired requestService: RequestService
|
||||
) {
|
||||
this.requestService = requestService
|
||||
|
||||
whenever(requestService.findAll(any<Pageable>())).thenReturn(Page.empty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testShouldShowHomePage() {
|
||||
val page = webClient.getPage<HtmlPage>("http://localhost/")
|
||||
assertThat(page.querySelectorAll("tbody tr")).isEmpty()
|
||||
assertThat(page.querySelectorAll("div.notification.info")).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "admin", roles = ["ADMIN"])
|
||||
fun testShouldThrowNotFoundExceptionForUnknownReport() {
|
||||
val requestId = randomRequestId()
|
||||
|
||||
whenever(requestService.findByUuid(anyValueClass())).thenReturn(
|
||||
Optional.empty()
|
||||
)
|
||||
|
||||
assertThrows<IOException> {
|
||||
webClient.getPage<HtmlPage>("http://localhost/report/${requestId.value}")
|
||||
}.also {
|
||||
assertThat(it).hasRootCauseInstanceOf(NotFoundException::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "admin", roles = ["ADMIN"])
|
||||
fun testShouldShowEmptyPatientDetails() {
|
||||
whenever(requestService.findRequestByPatientId(anyValueClass(), any<Pageable>())).thenReturn(Page.empty())
|
||||
|
||||
val page = webClient.getPage<HtmlPage>("http://localhost/patient/PSEUDO1")
|
||||
assertThat(page.querySelectorAll("tbody tr")).isEmpty()
|
||||
assertThat(page.querySelectorAll("div.notification.info")).hasSize(1)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,88 @@
|
||||
/*
|
||||
* This file is part of ETL-Processor
|
||||
*
|
||||
* Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.dnpm.etl.processor.web
|
||||
|
||||
import dev.dnpm.etl.processor.config.AppConfiguration
|
||||
import dev.dnpm.etl.processor.config.AppSecurityConfiguration
|
||||
import dev.dnpm.etl.processor.security.TokenService
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.htmlunit.WebClient
|
||||
import org.htmlunit.html.HtmlPage
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.extension.ExtendWith
|
||||
import org.mockito.junit.jupiter.MockitoExtension
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
|
||||
import org.springframework.test.context.ContextConfiguration
|
||||
import org.springframework.test.context.TestPropertySource
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean
|
||||
import org.springframework.test.context.junit.jupiter.SpringExtension
|
||||
import org.springframework.test.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.get
|
||||
import org.springframework.test.web.servlet.htmlunit.MockMvcWebClientBuilder
|
||||
|
||||
@WebMvcTest(controllers = [LoginController::class])
|
||||
@ExtendWith(value = [MockitoExtension::class, SpringExtension::class])
|
||||
@ContextConfiguration(
|
||||
classes = [
|
||||
LoginController::class,
|
||||
AppConfiguration::class,
|
||||
AppSecurityConfiguration::class
|
||||
]
|
||||
)
|
||||
@TestPropertySource(
|
||||
properties = [
|
||||
"app.pseudonymize.generator=BUILDIN",
|
||||
"app.security.admin-user=admin",
|
||||
"app.security.admin-password={noop}very-secret",
|
||||
"app.security.enable-tokens=true"
|
||||
]
|
||||
)
|
||||
@MockitoBean(
|
||||
types = [TokenService::class]
|
||||
)
|
||||
class LoginControllerTest {
|
||||
|
||||
private lateinit var mockMvc: MockMvc
|
||||
private lateinit var webClient: WebClient
|
||||
|
||||
@BeforeEach
|
||||
fun setup(@Autowired mockMvc: MockMvc) {
|
||||
this.mockMvc = mockMvc
|
||||
this.webClient = MockMvcWebClientBuilder.mockMvcSetup(mockMvc).build()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testShouldRequestLoginPage() {
|
||||
mockMvc.get("/login").andExpect {
|
||||
status { isOk() }
|
||||
view { name("login") }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testShouldShowLoginForm() {
|
||||
val page = webClient.getPage<HtmlPage>("http://localhost/login")
|
||||
assertThat(
|
||||
page.getElementsByTagName("main").first().firstElementChild.getAttribute("class")
|
||||
).isEqualTo("login-form")
|
||||
}
|
||||
}
|
@ -0,0 +1,73 @@
|
||||
/*
|
||||
* This file is part of ETL-Processor
|
||||
*
|
||||
* Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.dnpm.etl.processor.web
|
||||
|
||||
import dev.dnpm.etl.processor.config.AppConfiguration
|
||||
import dev.dnpm.etl.processor.config.AppSecurityConfiguration
|
||||
import org.htmlunit.WebClient
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.extension.ExtendWith
|
||||
import org.mockito.junit.jupiter.MockitoExtension
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
|
||||
import org.springframework.test.context.ContextConfiguration
|
||||
import org.springframework.test.context.TestPropertySource
|
||||
import org.springframework.test.context.junit.jupiter.SpringExtension
|
||||
import org.springframework.test.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.get
|
||||
import org.springframework.test.web.servlet.htmlunit.MockMvcWebClientBuilder
|
||||
|
||||
@WebMvcTest(controllers = [StatisticsController::class])
|
||||
@ExtendWith(value = [MockitoExtension::class, SpringExtension::class])
|
||||
@ContextConfiguration(
|
||||
classes = [
|
||||
StatisticsController::class,
|
||||
AppConfiguration::class,
|
||||
AppSecurityConfiguration::class
|
||||
]
|
||||
)
|
||||
@TestPropertySource(
|
||||
properties = [
|
||||
"app.pseudonymize.generator=BUILDIN",
|
||||
"app.security.admin-user=admin",
|
||||
"app.security.admin-password={noop}very-secret"
|
||||
]
|
||||
)
|
||||
class StatisticsControllerTest {
|
||||
|
||||
private lateinit var mockMvc: MockMvc
|
||||
private lateinit var webClient: WebClient
|
||||
|
||||
@BeforeEach
|
||||
fun setup(@Autowired mockMvc: MockMvc) {
|
||||
this.mockMvc = mockMvc
|
||||
this.webClient = MockMvcWebClientBuilder.mockMvcSetup(mockMvc).build()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testShouldRequestLoginPage() {
|
||||
mockMvc.get("/statistics").andExpect {
|
||||
status { isOk() }
|
||||
view { name("statistics") }
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,314 @@
|
||||
/*
|
||||
* This file is part of ETL-Processor
|
||||
*
|
||||
* Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.dnpm.etl.processor.web
|
||||
|
||||
import dev.dnpm.etl.processor.Fingerprint
|
||||
import dev.dnpm.etl.processor.PatientId
|
||||
import dev.dnpm.etl.processor.PatientPseudonym
|
||||
import dev.dnpm.etl.processor.config.AppConfiguration
|
||||
import dev.dnpm.etl.processor.config.AppSecurityConfiguration
|
||||
import dev.dnpm.etl.processor.monitoring.CountedState
|
||||
import dev.dnpm.etl.processor.monitoring.Request
|
||||
import dev.dnpm.etl.processor.monitoring.RequestStatus
|
||||
import dev.dnpm.etl.processor.monitoring.RequestType
|
||||
import dev.dnpm.etl.processor.randomRequestId
|
||||
import dev.dnpm.etl.processor.services.RequestService
|
||||
import org.hamcrest.Matchers.equalTo
|
||||
import org.hamcrest.Matchers.hasSize
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Nested
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.extension.ExtendWith
|
||||
import org.mockito.junit.jupiter.MockitoExtension
|
||||
import org.mockito.kotlin.doAnswer
|
||||
import org.mockito.kotlin.whenever
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
|
||||
import org.springframework.http.MediaType.TEXT_EVENT_STREAM
|
||||
import org.springframework.test.context.ContextConfiguration
|
||||
import org.springframework.test.context.TestPropertySource
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean
|
||||
import org.springframework.test.context.junit.jupiter.SpringExtension
|
||||
import org.springframework.test.web.reactive.server.WebTestClient
|
||||
import org.springframework.test.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.client.MockMvcWebTestClient
|
||||
import org.springframework.test.web.servlet.get
|
||||
import org.springframework.web.context.WebApplicationContext
|
||||
import reactor.core.publisher.Sinks
|
||||
import reactor.test.StepVerifier
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
|
||||
@WebMvcTest(controllers = [StatisticsRestController::class])
|
||||
@ExtendWith(value = [MockitoExtension::class, SpringExtension::class])
|
||||
@ContextConfiguration(
|
||||
classes = [
|
||||
StatisticsRestController::class,
|
||||
AppConfiguration::class,
|
||||
AppSecurityConfiguration::class
|
||||
]
|
||||
)
|
||||
@TestPropertySource(
|
||||
properties = [
|
||||
"app.pseudonymize.generator=BUILDIN",
|
||||
"app.security.admin-user=admin",
|
||||
"app.security.admin-password={noop}very-secret"
|
||||
]
|
||||
)
|
||||
@MockitoBean(
|
||||
types = [RequestService::class]
|
||||
)
|
||||
class StatisticsRestControllerTest {
|
||||
|
||||
private lateinit var mockMvc: MockMvc
|
||||
|
||||
private lateinit var statisticsUpdateProducer: Sinks.Many<Any>
|
||||
private lateinit var requestService: RequestService
|
||||
|
||||
@BeforeEach
|
||||
fun setup(
|
||||
@Autowired mockMvc: MockMvc,
|
||||
@Autowired statisticsUpdateProducer: Sinks.Many<Any>,
|
||||
@Autowired requestService: RequestService
|
||||
) {
|
||||
this.mockMvc = mockMvc
|
||||
this.statisticsUpdateProducer = statisticsUpdateProducer
|
||||
this.requestService = requestService
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class RequestStatesTest {
|
||||
@Test
|
||||
fun testShouldRequestStatesForMtbFiles() {
|
||||
doAnswer { _ ->
|
||||
listOf(
|
||||
CountedState(42, RequestStatus.WARNING),
|
||||
CountedState(1, RequestStatus.UNKNOWN)
|
||||
)
|
||||
}.whenever(requestService).countStates()
|
||||
|
||||
mockMvc.get("/statistics/requeststates").andExpect {
|
||||
status { isOk() }.also {
|
||||
jsonPath("$", hasSize<Int>(2))
|
||||
jsonPath("$[0].name", equalTo(RequestStatus.WARNING.name))
|
||||
jsonPath("$[0].value", equalTo(42))
|
||||
jsonPath("$[1].name", equalTo(RequestStatus.UNKNOWN.name))
|
||||
jsonPath("$[1].value", equalTo(1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testShouldRequestStatesForDeletes() {
|
||||
doAnswer { _ ->
|
||||
listOf(
|
||||
CountedState(42, RequestStatus.SUCCESS),
|
||||
CountedState(1, RequestStatus.ERROR)
|
||||
)
|
||||
}.whenever(requestService).countDeleteStates()
|
||||
|
||||
mockMvc.get("/statistics/requeststates?delete=true").andExpect {
|
||||
status { isOk() }.also {
|
||||
jsonPath("$", hasSize<Int>(2))
|
||||
jsonPath("$[0].name", equalTo(RequestStatus.SUCCESS.name))
|
||||
jsonPath("$[0].value", equalTo(42))
|
||||
jsonPath("$[1].name", equalTo(RequestStatus.ERROR.name))
|
||||
jsonPath("$[1].value", equalTo(1))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class PatientRequestStatesTest {
|
||||
@Test
|
||||
fun testShouldRequestPatientStatesForMtbFiles() {
|
||||
doAnswer { _ ->
|
||||
listOf(
|
||||
CountedState(42, RequestStatus.WARNING),
|
||||
CountedState(1, RequestStatus.UNKNOWN)
|
||||
)
|
||||
}.whenever(requestService).findPatientUniqueStates()
|
||||
|
||||
mockMvc.get("/statistics/requestpatientstates").andExpect {
|
||||
status { isOk() }.also {
|
||||
jsonPath("$", hasSize<Int>(2))
|
||||
jsonPath("$[0].name", equalTo(RequestStatus.WARNING.name))
|
||||
jsonPath("$[0].value", equalTo(42))
|
||||
jsonPath("$[1].name", equalTo(RequestStatus.UNKNOWN.name))
|
||||
jsonPath("$[1].value", equalTo(1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testShouldRequestPatientStatesForDeletes() {
|
||||
doAnswer { _ ->
|
||||
listOf(
|
||||
CountedState(42, RequestStatus.SUCCESS),
|
||||
CountedState(1, RequestStatus.ERROR)
|
||||
)
|
||||
}.whenever(requestService).findPatientUniqueDeleteStates()
|
||||
|
||||
mockMvc.get("/statistics/requestpatientstates?delete=true").andExpect {
|
||||
status { isOk() }.also {
|
||||
jsonPath("$", hasSize<Int>(2))
|
||||
jsonPath("$[0].name", equalTo(RequestStatus.SUCCESS.name))
|
||||
jsonPath("$[0].value", equalTo(42))
|
||||
jsonPath("$[1].name", equalTo(RequestStatus.ERROR.name))
|
||||
jsonPath("$[1].value", equalTo(1))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class LastMonthStatesTest {
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
val zoneId = ZoneId.of("Europe/Berlin")
|
||||
doAnswer { _ ->
|
||||
listOf(
|
||||
Request(
|
||||
1,
|
||||
randomRequestId(),
|
||||
PatientPseudonym("TEST_12345678901"),
|
||||
PatientId("P1"),
|
||||
Fingerprint("0123456789abcdef1"),
|
||||
RequestType.MTB_FILE,
|
||||
RequestStatus.SUCCESS,
|
||||
Instant.now().atZone(zoneId).truncatedTo(ChronoUnit.DAYS).minus(2, ChronoUnit.DAYS).toInstant()
|
||||
),
|
||||
Request(
|
||||
2,
|
||||
randomRequestId(),
|
||||
PatientPseudonym("TEST_12345678902"),
|
||||
PatientId("P2"),
|
||||
Fingerprint("0123456789abcdef2"),
|
||||
RequestType.MTB_FILE,
|
||||
RequestStatus.WARNING,
|
||||
Instant.now().atZone(zoneId).truncatedTo(ChronoUnit.DAYS).minus(2, ChronoUnit.DAYS).toInstant()
|
||||
),
|
||||
Request(
|
||||
3,
|
||||
randomRequestId(),
|
||||
PatientPseudonym("TEST_12345678901"),
|
||||
PatientId("P2"),
|
||||
Fingerprint("0123456789abcdee1"),
|
||||
RequestType.DELETE,
|
||||
RequestStatus.ERROR,
|
||||
Instant.now().atZone(zoneId).truncatedTo(ChronoUnit.DAYS).minus(1, ChronoUnit.DAYS).toInstant()
|
||||
),
|
||||
Request(
|
||||
4,
|
||||
randomRequestId(),
|
||||
PatientPseudonym("TEST_12345678902"),
|
||||
PatientId("P2"),
|
||||
Fingerprint("0123456789abcdef2"),
|
||||
RequestType.MTB_FILE,
|
||||
RequestStatus.DUPLICATION,
|
||||
Instant.now().atZone(zoneId).truncatedTo(ChronoUnit.DAYS).minus(1, ChronoUnit.DAYS).toInstant()
|
||||
),
|
||||
Request(
|
||||
5,
|
||||
randomRequestId(),
|
||||
PatientPseudonym("TEST_12345678902"),
|
||||
PatientId("P2"),
|
||||
Fingerprint("0123456789abcdef2"),
|
||||
RequestType.DELETE,
|
||||
RequestStatus.UNKNOWN,
|
||||
Instant.now().atZone(zoneId).truncatedTo(ChronoUnit.DAYS).toInstant()
|
||||
),
|
||||
)
|
||||
}.whenever(requestService).findAll()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testShouldRequestLastMonthForMtbFiles() {
|
||||
mockMvc.get("/statistics/requestslastmonth").andExpect {
|
||||
status { isOk() }.also {
|
||||
jsonPath("$", hasSize<Int>(31))
|
||||
}.also {
|
||||
jsonPath("$[28].nameValues.error", equalTo(0))
|
||||
jsonPath("$[28].nameValues.warning", equalTo(1))
|
||||
jsonPath("$[28].nameValues.success", equalTo(1))
|
||||
jsonPath("$[28].nameValues.duplication", equalTo(0))
|
||||
jsonPath("$[28].nameValues.unknown", equalTo(0))
|
||||
jsonPath("$[29].nameValues.error", equalTo(0))
|
||||
jsonPath("$[29].nameValues.warning", equalTo(0))
|
||||
jsonPath("$[29].nameValues.success", equalTo(0))
|
||||
jsonPath("$[29].nameValues.duplication", equalTo(1))
|
||||
jsonPath("$[29].nameValues.unknown", equalTo(0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testShouldRequestLastMonthForDeletes() {
|
||||
mockMvc.get("/statistics/requestslastmonth?delete=true").andExpect {
|
||||
status { isOk() }.also {
|
||||
jsonPath("$", hasSize<Int>(31))
|
||||
}.also {
|
||||
jsonPath("$[29].nameValues.error", equalTo(1))
|
||||
jsonPath("$[29].nameValues.warning", equalTo(0))
|
||||
jsonPath("$[29].nameValues.success", equalTo(0))
|
||||
jsonPath("$[29].nameValues.duplication", equalTo(0))
|
||||
jsonPath("$[29].nameValues.unknown", equalTo(0))
|
||||
jsonPath("$[30].nameValues.error", equalTo(0))
|
||||
jsonPath("$[30].nameValues.warning", equalTo(0))
|
||||
jsonPath("$[30].nameValues.success", equalTo(0))
|
||||
jsonPath("$[30].nameValues.duplication", equalTo(0))
|
||||
jsonPath("$[30].nameValues.unknown", equalTo(1))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class SseTest {
|
||||
private lateinit var webClient: WebTestClient
|
||||
|
||||
@BeforeEach
|
||||
fun setup(
|
||||
applicationContext: WebApplicationContext,
|
||||
) {
|
||||
this.webClient = MockMvcWebTestClient
|
||||
.bindToApplicationContext(applicationContext).build()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testShouldRequestSSE() {
|
||||
statisticsUpdateProducer.emitComplete { _, _ -> true }
|
||||
|
||||
val result = webClient.get().uri("http://localhost/statistics/events").accept(TEXT_EVENT_STREAM).exchange()
|
||||
.expectStatus().isOk()
|
||||
.expectHeader().contentType(TEXT_EVENT_STREAM)
|
||||
.returnResult(String::class.java)
|
||||
|
||||
StepVerifier.create(result.responseBody)
|
||||
.expectComplete()
|
||||
.verify()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,18 @@
|
||||
package dev.dnpm.etl.processor.consent;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
public class ConsentCheckFileBased implements ICheckConsent{
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ConsentCheckFileBased.class);
|
||||
|
||||
public ConsentCheckFileBased() {
|
||||
log.info("ConsentCheckFileBased initialized...");
|
||||
}
|
||||
|
||||
@Override
|
||||
public TtpConsentStatus getTtpConsentStatus(String personIdentifierValue) {
|
||||
return TtpConsentStatus.UNKNOWN_CHECK_FILE;
|
||||
}
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
package dev.dnpm.etl.processor.consent;
|
||||
|
||||
public enum ConsentDomain {
|
||||
BroadConsent,
|
||||
Modelvorhaben64e
|
||||
}
|
@ -0,0 +1,281 @@
|
||||
package dev.dnpm.etl.processor.consent;
|
||||
|
||||
import ca.uhn.fhir.context.FhirContext;
|
||||
import ca.uhn.fhir.parser.DataFormatException;
|
||||
import dev.dnpm.etl.processor.config.AppFhirConfig;
|
||||
import dev.dnpm.etl.processor.config.GIcsConfigProperties;
|
||||
import java.util.Date;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.hl7.fhir.instance.model.api.IBaseResource;
|
||||
import org.hl7.fhir.r4.model.BooleanType;
|
||||
import org.hl7.fhir.r4.model.Bundle;
|
||||
import org.hl7.fhir.r4.model.Coding;
|
||||
import org.hl7.fhir.r4.model.DateType;
|
||||
import org.hl7.fhir.r4.model.Identifier;
|
||||
import org.hl7.fhir.r4.model.OperationOutcome;
|
||||
import org.hl7.fhir.r4.model.Parameters;
|
||||
import org.hl7.fhir.r4.model.Parameters.ParametersParameterComponent;
|
||||
import org.hl7.fhir.r4.model.StringType;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.retry.TerminatedRetryException;
|
||||
import org.springframework.retry.support.RetryTemplate;
|
||||
import org.springframework.web.client.RestClientException;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
|
||||
public class GicsConsentService implements ICheckConsent {
|
||||
|
||||
private final Logger log = LoggerFactory.getLogger(GicsConsentService.class);
|
||||
|
||||
private final GIcsConfigProperties gIcsConfigProperties;
|
||||
|
||||
public static final String IS_CONSENTED_ENDPOINT = "/$isConsented";
|
||||
public static final String IS_POLICY_STATES_FOR_PERSON_ENDPOINT = "/$currentPolicyStatesForPerson";
|
||||
private final RetryTemplate retryTemplate;
|
||||
private final RestTemplate restTemplate;
|
||||
private final FhirContext fhirContext;
|
||||
private final HttpHeaders httpHeader;
|
||||
private String url;
|
||||
|
||||
public GicsConsentService(GIcsConfigProperties gIcsConfigProperties,
|
||||
RetryTemplate retryTemplate, RestTemplate restTemplate, AppFhirConfig appFhirConfig) {
|
||||
this.gIcsConfigProperties = gIcsConfigProperties;
|
||||
this.retryTemplate = retryTemplate;
|
||||
this.restTemplate = restTemplate;
|
||||
this.fhirContext = appFhirConfig.fhirContext();
|
||||
httpHeader = buildHeader(gIcsConfigProperties.getUsername(),
|
||||
gIcsConfigProperties.getPassword());
|
||||
log.info("GicsConsentService initialized...");
|
||||
}
|
||||
|
||||
public String getGicsUri(String endpoint) {
|
||||
if (url == null) {
|
||||
final String gIcsBaseUri = gIcsConfigProperties.getUri();
|
||||
if (StringUtils.isBlank(gIcsBaseUri)) {
|
||||
throw new IllegalArgumentException(
|
||||
"gICS base URL is empty - should call gICS with false configuration.");
|
||||
}
|
||||
url = UriComponentsBuilder.fromUriString(gIcsBaseUri).path(IS_CONSENTED_ENDPOINT)
|
||||
.toUriString();
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
@NotNull
|
||||
private static HttpHeaders buildHeader(String gPasUserName, String gPasPassword) {
|
||||
var headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_XML);
|
||||
|
||||
if (StringUtils.isBlank(gPasUserName) || StringUtils.isBlank(gPasPassword)) {
|
||||
return headers;
|
||||
}
|
||||
|
||||
headers.setBasicAuth(gPasUserName, gPasPassword);
|
||||
return headers;
|
||||
}
|
||||
|
||||
protected static Parameters getIsConsentedRequestParam(GIcsConfigProperties configProperties,
|
||||
String personIdentifierValue) {
|
||||
var result = new Parameters();
|
||||
result.addParameter(new ParametersParameterComponent().setName("personIdentifier").setValue(
|
||||
new Identifier().setValue(personIdentifierValue)
|
||||
.setSystem(configProperties.getPersonIdentifierSystem())));
|
||||
result.addParameter(new ParametersParameterComponent().setName("domain")
|
||||
.setValue(new StringType().setValue(configProperties.getBroadConsentDomainName())));
|
||||
result.addParameter(new ParametersParameterComponent().setName("policy").setValue(
|
||||
new Coding().setCode(configProperties.getPolicyCode())
|
||||
.setSystem(configProperties.getPolicySystem())));
|
||||
|
||||
/*
|
||||
* is mandatory parameter, but we ignore it via additional configuration parameter
|
||||
* 'ignoreVersionNumber'.
|
||||
*/
|
||||
result.addParameter(new ParametersParameterComponent().setName("version")
|
||||
.setValue(new StringType().setValue("1.1")));
|
||||
|
||||
/* add config parameter with:
|
||||
* ignoreVersionNumber -> true ->> Reason is we cannot know which policy version each patient
|
||||
* has possibly signed or not, therefore we are happy with any version found.
|
||||
* unknownStateIsConsideredAsDecline -> true
|
||||
*/
|
||||
var config = new ParametersParameterComponent().setName("config").addPart(
|
||||
new ParametersParameterComponent().setName("ignoreVersionNumber")
|
||||
.setValue(new BooleanType().setValue(true))).addPart(
|
||||
new ParametersParameterComponent().setName("unknownStateIsConsideredAsDecline")
|
||||
.setValue(new BooleanType().setValue(false)));
|
||||
result.addParameter(config);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
protected String callGicsApi(Parameters parameter, String endpoint) {
|
||||
var parameterAsXml = fhirContext.newXmlParser().encodeResourceToString(parameter);
|
||||
|
||||
HttpEntity<String> requestEntity = new HttpEntity<>(parameterAsXml, this.httpHeader);
|
||||
ResponseEntity<String> responseEntity;
|
||||
try {
|
||||
var url = getGicsUri(endpoint);
|
||||
|
||||
responseEntity = retryTemplate.execute(
|
||||
ctx -> restTemplate.exchange(url, HttpMethod.POST, requestEntity, String.class));
|
||||
} catch (RestClientException e) {
|
||||
var msg = String.format("Get consents status request failed reason: '%s",
|
||||
e.getMessage());
|
||||
log.error(msg);
|
||||
return null;
|
||||
|
||||
} catch (TerminatedRetryException terminatedRetryException) {
|
||||
var msg = String.format(
|
||||
"Get consents status process has been terminated. termination reason: '%s",
|
||||
terminatedRetryException.getMessage());
|
||||
log.error(msg);
|
||||
return null;
|
||||
|
||||
}
|
||||
if (responseEntity.getStatusCode().is2xxSuccessful()) {
|
||||
return responseEntity.getBody();
|
||||
} else {
|
||||
var msg = String.format(
|
||||
"Trusted party system reached but request failed! code: '%s' response: '%s'",
|
||||
responseEntity.getStatusCode(), responseEntity.getBody());
|
||||
log.error(msg);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public TtpConsentStatus getTtpConsentStatus(String personIdentifierValue) {
|
||||
var parameter = GicsConsentService.getIsConsentedRequestParam(gIcsConfigProperties,
|
||||
personIdentifierValue);
|
||||
|
||||
var consentStatusResponse = callGicsApi(parameter,
|
||||
GicsConsentService.IS_CONSENTED_ENDPOINT);
|
||||
return evaluateConsentResponse(consentStatusResponse);
|
||||
}
|
||||
|
||||
public Bundle currentConsentForPersonAndTemplate(String personIdentifierValue,
|
||||
ConsentDomain targetConsentDomain, Date requestDate) {
|
||||
|
||||
String consentDomain = getConsentDomain(targetConsentDomain);
|
||||
|
||||
var requestParameter = GicsConsentService.buildRequestParameterCurrentPolicyStatesForPerson(
|
||||
gIcsConfigProperties, personIdentifierValue, requestDate, consentDomain);
|
||||
|
||||
var consentDataSerialized = callGicsApi(requestParameter,
|
||||
GicsConsentService.IS_POLICY_STATES_FOR_PERSON_ENDPOINT);
|
||||
|
||||
if (consentDataSerialized == null) {
|
||||
// error occurred - should not process further!
|
||||
throw new IllegalStateException(
|
||||
"consent data request failed - stopping processing! - try again or fix other problems first.");
|
||||
}
|
||||
IBaseResource iBaseResource = fhirContext.newXmlParser()
|
||||
.parseResource(consentDataSerialized);
|
||||
if (iBaseResource instanceof OperationOutcome) {
|
||||
// log error - very likely a configuration error
|
||||
String errorMessage =
|
||||
"Consent request failed! Check outcome:\n " + consentDataSerialized;
|
||||
log.error(errorMessage);
|
||||
throw new IllegalStateException(errorMessage);
|
||||
} else if (iBaseResource instanceof Bundle) {
|
||||
return (Bundle) iBaseResource;
|
||||
} else {
|
||||
String errorMessage = "Consent request failed! Unexpected response received! -> "
|
||||
+ consentDataSerialized;
|
||||
log.error(errorMessage);
|
||||
throw new IllegalStateException(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
@NotNull
|
||||
private String getConsentDomain(ConsentDomain targetConsentDomain) {
|
||||
String consentDomain;
|
||||
switch (targetConsentDomain) {
|
||||
case BroadConsent -> {
|
||||
consentDomain = gIcsConfigProperties.getBroadConsentDomainName();
|
||||
}
|
||||
case Modelvorhaben64e -> {
|
||||
consentDomain = gIcsConfigProperties.getGnomDeConsentDomainName();
|
||||
}
|
||||
default -> {
|
||||
throw new IllegalArgumentException(
|
||||
"target ConsentDomain is missing but must be provided!");
|
||||
}
|
||||
}
|
||||
return consentDomain;
|
||||
}
|
||||
|
||||
public Bundle getBroadConsent(String personIdentifierValue, Date requestDate) {
|
||||
return currentConsentForPersonAndTemplate(personIdentifierValue, ConsentDomain.BroadConsent,
|
||||
requestDate);
|
||||
}
|
||||
|
||||
public Bundle getGenomDeConsent(String personIdentifierValue, Date requestDate) {
|
||||
return currentConsentForPersonAndTemplate(personIdentifierValue,
|
||||
ConsentDomain.Modelvorhaben64e, requestDate);
|
||||
}
|
||||
|
||||
protected static Parameters buildRequestParameterCurrentPolicyStatesForPerson(
|
||||
GIcsConfigProperties gIcsConfigProperties, String personIdentifierValue, Date requestDate,
|
||||
String targetDomain) {
|
||||
var requestParameter = new Parameters();
|
||||
requestParameter.addParameter(new ParametersParameterComponent().setName("personIdentifier")
|
||||
.setValue(new Identifier().setValue(personIdentifierValue)
|
||||
.setSystem(gIcsConfigProperties.getPersonIdentifierSystem())));
|
||||
|
||||
requestParameter.addParameter(new ParametersParameterComponent().setName("domain")
|
||||
.setValue(new StringType().setValue(targetDomain)));
|
||||
|
||||
Parameters nestedConfigParameters = new Parameters();
|
||||
nestedConfigParameters.addParameter(
|
||||
new ParametersParameterComponent().setName("idMatchingType").setValue(
|
||||
new Coding().setSystem(
|
||||
"https://ths-greifswald.de/fhir/CodeSystem/gics/IdMatchingType")
|
||||
.setCode("AT_LEAST_ONE"))).addParameter("ignoreVersionNumber", false)
|
||||
.addParameter("unknownStateIsConsideredAsDecline", false)
|
||||
.addParameter("requestDate", new DateType().setValue(requestDate));
|
||||
|
||||
requestParameter.addParameter(new ParametersParameterComponent().setName("config").addPart()
|
||||
.setResource(nestedConfigParameters));
|
||||
|
||||
return requestParameter;
|
||||
}
|
||||
|
||||
private TtpConsentStatus evaluateConsentResponse(String consentStatusResponse) {
|
||||
if (consentStatusResponse == null) {
|
||||
return TtpConsentStatus.FAILED_TO_ASK;
|
||||
}
|
||||
try {
|
||||
var response = fhirContext.newJsonParser().parseResource(consentStatusResponse);
|
||||
|
||||
if (response instanceof Parameters responseParameters) {
|
||||
|
||||
var responseValue = responseParameters.getParameter("consented").getValue();
|
||||
var isConsented = responseValue.castToBoolean(responseValue);
|
||||
if (!isConsented.hasValue()) {
|
||||
return TtpConsentStatus.FAILED_TO_ASK;
|
||||
}
|
||||
if (isConsented.booleanValue()) {
|
||||
return TtpConsentStatus.CONSENTED;
|
||||
} else {
|
||||
return TtpConsentStatus.CONSENT_MISSING_OR_REJECTED;
|
||||
}
|
||||
} else if (response instanceof OperationOutcome outcome) {
|
||||
log.error("failed to get consent status from ttp. probably configuration error. "
|
||||
+ "outcome: '{}'", fhirContext.newJsonParser().encodeToString(outcome));
|
||||
|
||||
}
|
||||
} catch (DataFormatException dfe) {
|
||||
log.error("failed to parse response to FHIR R4 resource.", dfe);
|
||||
}
|
||||
return TtpConsentStatus.FAILED_TO_ASK;
|
||||
}
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
package dev.dnpm.etl.processor.consent;
|
||||
|
||||
|
||||
public interface ICheckConsent {
|
||||
|
||||
TtpConsentStatus getTtpConsentStatus(String personIdentifierValue);
|
||||
|
||||
}
|
@ -0,0 +1,20 @@
|
||||
package dev.dnpm.etl.processor.consent;
|
||||
|
||||
public enum TtpConsentStatus {
|
||||
/**
|
||||
* Valid consent found
|
||||
*/
|
||||
CONSENTED,
|
||||
|
||||
CONSENT_MISSING_OR_REJECTED,
|
||||
|
||||
/**
|
||||
* Due technical problems consent status is unknown
|
||||
*/
|
||||
FAILED_TO_ASK,
|
||||
|
||||
/**
|
||||
* Consent status is validate via file property 'consent.status'
|
||||
*/
|
||||
UNKNOWN_CHECK_FILE
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
/*
|
||||
* This file is part of ETL-Processor
|
||||
*
|
||||
* Copyright (c) 2023 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
* Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
@ -21,80 +21,41 @@ package dev.dnpm.etl.processor.pseudonym;
|
||||
|
||||
import ca.uhn.fhir.context.FhirContext;
|
||||
import ca.uhn.fhir.parser.IParser;
|
||||
import dev.dnpm.etl.processor.config.AppFhirConfig;
|
||||
import dev.dnpm.etl.processor.config.GPasConfigProperties;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
|
||||
import org.apache.hc.client5.http.impl.classic.HttpClients;
|
||||
import org.apache.hc.client5.http.impl.io.BasicHttpClientConnectionManager;
|
||||
import org.apache.hc.client5.http.socket.ConnectionSocketFactory;
|
||||
import org.apache.hc.client5.http.socket.PlainConnectionSocketFactory;
|
||||
import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory;
|
||||
import org.apache.hc.core5.http.config.Registry;
|
||||
import org.apache.hc.core5.http.config.RegistryBuilder;
|
||||
import org.hl7.fhir.r4.model.Identifier;
|
||||
import org.hl7.fhir.r4.model.Parameters;
|
||||
import org.hl7.fhir.r4.model.Parameters.ParametersParameterComponent;
|
||||
import org.hl7.fhir.r4.model.StringType;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.*;
|
||||
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
|
||||
import org.springframework.retry.RetryCallback;
|
||||
import org.springframework.retry.RetryContext;
|
||||
import org.springframework.retry.RetryListener;
|
||||
import org.springframework.retry.RetryPolicy;
|
||||
import org.springframework.retry.backoff.ExponentialBackOffPolicy;
|
||||
import org.springframework.retry.policy.SimpleRetryPolicy;
|
||||
import org.springframework.retry.support.RetryTemplate;
|
||||
import org.springframework.web.client.RestClientException;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import javax.net.ssl.SSLContext;
|
||||
import javax.net.ssl.TrustManagerFactory;
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.ConnectException;
|
||||
import java.security.KeyManagementException;
|
||||
import java.security.KeyStore;
|
||||
import java.security.KeyStoreException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.cert.CertificateException;
|
||||
import java.security.cert.CertificateFactory;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
|
||||
public class GpasPseudonymGenerator implements Generator {
|
||||
|
||||
private final FhirContext r4Context;
|
||||
private final String gPasUrl;
|
||||
private final String psnTargetDomain;
|
||||
private static FhirContext r4Context = FhirContext.forR4();
|
||||
private final HttpHeaders httpHeader;
|
||||
|
||||
private final RetryTemplate retryTemplate = defaultTemplate();
|
||||
|
||||
private final RetryTemplate retryTemplate;
|
||||
private final Logger log = LoggerFactory.getLogger(GpasPseudonymGenerator.class);
|
||||
|
||||
private SSLContext customSslContext;
|
||||
private RestTemplate restTemplate;
|
||||
|
||||
public GpasPseudonymGenerator(GPasConfigProperties gpasCfg) {
|
||||
private final RestTemplate restTemplate;
|
||||
|
||||
public GpasPseudonymGenerator(GPasConfigProperties gpasCfg, RetryTemplate retryTemplate,
|
||||
RestTemplate restTemplate, AppFhirConfig appFhirConfig) {
|
||||
this.retryTemplate = retryTemplate;
|
||||
this.restTemplate = restTemplate;
|
||||
this.gPasUrl = gpasCfg.getUri();
|
||||
this.psnTargetDomain = gpasCfg.getTarget();
|
||||
this.r4Context = appFhirConfig.fhirContext();
|
||||
httpHeader = getHttpHeaders(gpasCfg.getUsername(), gpasCfg.getPassword());
|
||||
|
||||
try {
|
||||
if (StringUtils.isNotBlank(gpasCfg.getSslCaLocation())) {
|
||||
customSslContext = getSslContext(gpasCfg.getSslCaLocation());
|
||||
}
|
||||
} catch (IOException | KeyManagementException | KeyStoreException | CertificateException |
|
||||
NoSuchAlgorithmException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
log.debug(String.format("%s has been initialized", this.getClass().getName()));
|
||||
|
||||
}
|
||||
|
||||
@ -110,21 +71,40 @@ public class GpasPseudonymGenerator implements Generator {
|
||||
|
||||
@NotNull
|
||||
public static String unwrapPseudonym(Parameters gPasPseudonymResult) {
|
||||
Identifier pseudonym = (Identifier) gPasPseudonymResult.getParameter().stream().findFirst()
|
||||
.get().getPart().stream().filter(a -> a.getName().equals("pseudonym")).findFirst()
|
||||
final var parameters = gPasPseudonymResult.getParameter().stream().findFirst();
|
||||
|
||||
if (parameters.isEmpty()) {
|
||||
throw new PseudonymRequestFailed("Empty HL7 parameters, cannot find first one");
|
||||
}
|
||||
|
||||
final var identifier = (Identifier) parameters.get().getPart().stream()
|
||||
.filter(a -> a.getName().equals("pseudonym"))
|
||||
.findFirst()
|
||||
.orElseGet(ParametersParameterComponent::new).getValue();
|
||||
|
||||
// pseudonym
|
||||
return pseudonym.getSystem() + "|" + pseudonym.getValue();
|
||||
return sanitizeValue(identifier.getValue());
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow only filename friendly values
|
||||
*
|
||||
* @param psnValue GAPS pseudonym value
|
||||
* @return cleaned up value
|
||||
*/
|
||||
public static String sanitizeValue(String psnValue) {
|
||||
// pattern to match forbidden characters
|
||||
String forbiddenCharsRegex = "[\\\\/:*?\"<>|;]";
|
||||
|
||||
// Replace all forbidden characters with underscores
|
||||
return psnValue.replaceAll(forbiddenCharsRegex, "_");
|
||||
}
|
||||
|
||||
@NotNull
|
||||
protected ResponseEntity<String> getGpasPseudonym(String gPasRequestBody) {
|
||||
|
||||
HttpEntity<String> requestEntity = new HttpEntity<>(gPasRequestBody, this.httpHeader);
|
||||
ResponseEntity<String> responseEntity;
|
||||
var restTemplate = getRestTemplete();
|
||||
|
||||
try {
|
||||
responseEntity = retryTemplate.execute(
|
||||
@ -164,99 +144,7 @@ public class GpasPseudonymGenerator implements Generator {
|
||||
return headers;
|
||||
}
|
||||
|
||||
String authHeader = gPasUserName + ":" + gPasPassword;
|
||||
byte[] authHeaderBytes = authHeader.getBytes();
|
||||
byte[] encodedAuthHeaderBytes = Base64.getEncoder().encode(authHeaderBytes);
|
||||
String encodedAuthHeader = new String(encodedAuthHeaderBytes);
|
||||
|
||||
if (StringUtils.isNotBlank(gPasUserName) && StringUtils.isNotBlank(gPasPassword)) {
|
||||
headers.set("Authorization", "Basic " + encodedAuthHeader);
|
||||
}
|
||||
|
||||
headers.setBasicAuth(gPasUserName, gPasPassword);
|
||||
return headers;
|
||||
}
|
||||
|
||||
protected RetryTemplate defaultTemplate() {
|
||||
RetryTemplate retryTemplate = new RetryTemplate();
|
||||
ExponentialBackOffPolicy backOffPolicy = new ExponentialBackOffPolicy();
|
||||
backOffPolicy.setInitialInterval(1000);
|
||||
backOffPolicy.setMultiplier(1.25);
|
||||
retryTemplate.setBackOffPolicy(backOffPolicy);
|
||||
HashMap<Class<? extends Throwable>, Boolean> retryableExceptions = new HashMap<>();
|
||||
retryableExceptions.put(RestClientException.class, true);
|
||||
retryableExceptions.put(ConnectException.class, true);
|
||||
RetryPolicy retryPolicy = new SimpleRetryPolicy(3, retryableExceptions);
|
||||
retryTemplate.setRetryPolicy(retryPolicy);
|
||||
|
||||
retryTemplate.registerListener(new RetryListener() {
|
||||
@Override
|
||||
public <T, E extends Throwable> void onError(RetryContext context,
|
||||
RetryCallback<T, E> callback, Throwable throwable) {
|
||||
log.warn("HTTP Error occurred: {}. Retrying {}", throwable.getMessage(),
|
||||
context.getRetryCount());
|
||||
RetryListener.super.onError(context, callback, throwable);
|
||||
}
|
||||
});
|
||||
|
||||
return retryTemplate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read SSL root certificate and return SSLContext
|
||||
*
|
||||
* @param certificateLocation file location to root certificate (PEM)
|
||||
* @return initialized SSLContext
|
||||
* @throws IOException file cannot be read
|
||||
* @throws CertificateException in case we have an invalid certificate of type X.509
|
||||
* @throws KeyStoreException keystore cannot be initialized
|
||||
* @throws NoSuchAlgorithmException missing trust manager algorithmus
|
||||
* @throws KeyManagementException key management failed at init SSLContext
|
||||
*/
|
||||
@Nullable
|
||||
protected SSLContext getSslContext(String certificateLocation)
|
||||
throws IOException, CertificateException, KeyStoreException, KeyManagementException, NoSuchAlgorithmException {
|
||||
|
||||
KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType());
|
||||
|
||||
FileInputStream fis = new FileInputStream(certificateLocation);
|
||||
X509Certificate ca = (X509Certificate) CertificateFactory.getInstance("X.509")
|
||||
.generateCertificate(new BufferedInputStream(fis));
|
||||
|
||||
ks.load(null, null);
|
||||
ks.setCertificateEntry(Integer.toString(1), ca);
|
||||
|
||||
TrustManagerFactory tmf = TrustManagerFactory.getInstance(
|
||||
TrustManagerFactory.getDefaultAlgorithm());
|
||||
tmf.init(ks);
|
||||
|
||||
SSLContext sslContext = SSLContext.getInstance("TLS");
|
||||
sslContext.init(null, tmf.getTrustManagers(), null);
|
||||
|
||||
return sslContext;
|
||||
}
|
||||
|
||||
protected RestTemplate getRestTemplete() {
|
||||
|
||||
if (restTemplate != null) {
|
||||
return restTemplate;
|
||||
}
|
||||
|
||||
if (customSslContext == null) {
|
||||
restTemplate = new RestTemplate();
|
||||
return restTemplate;
|
||||
}
|
||||
final var sslsf = new SSLConnectionSocketFactory(customSslContext);
|
||||
final Registry<ConnectionSocketFactory> socketFactoryRegistry = RegistryBuilder.<ConnectionSocketFactory>create()
|
||||
.register("https", sslsf).register("http", new PlainConnectionSocketFactory()).build();
|
||||
|
||||
final BasicHttpClientConnectionManager connectionManager = new BasicHttpClientConnectionManager(
|
||||
socketFactoryRegistry);
|
||||
final CloseableHttpClient httpClient = HttpClients.custom()
|
||||
.setConnectionManager(connectionManager).build();
|
||||
|
||||
final HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory(
|
||||
httpClient);
|
||||
restTemplate = new RestTemplate(requestFactory);
|
||||
return restTemplate;
|
||||
}
|
||||
}
|
||||
|
@ -20,9 +20,10 @@
|
||||
package dev.dnpm.etl.processor
|
||||
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||
import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration
|
||||
import org.springframework.boot.runApplication
|
||||
|
||||
@SpringBootApplication
|
||||
@SpringBootApplication(exclude = [SecurityAutoConfiguration::class])
|
||||
class EtlProcessorApplication
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
|
@ -1,7 +1,7 @@
|
||||
/*
|
||||
* This file is part of ETL-Processor
|
||||
*
|
||||
* Copyright (c) 2023 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
@ -19,12 +19,15 @@
|
||||
|
||||
package dev.dnpm.etl.processor.config
|
||||
|
||||
import dev.dnpm.etl.processor.security.Role
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties
|
||||
|
||||
@ConfigurationProperties(AppConfigProperties.NAME)
|
||||
data class AppConfigProperties(
|
||||
var bwhc_uri: String?,
|
||||
var generator: PseudonymGenerator = PseudonymGenerator.BUILDIN
|
||||
var bwhcUri: String?,
|
||||
var transformations: List<TransformationProperties> = listOf(),
|
||||
var maxRetryAttempts: Int = 3,
|
||||
var duplicationDetection: Boolean = true
|
||||
) {
|
||||
companion object {
|
||||
const val NAME = "app"
|
||||
@ -33,6 +36,7 @@ data class AppConfigProperties(
|
||||
|
||||
@ConfigurationProperties(PseudonymizeConfigProperties.NAME)
|
||||
data class PseudonymizeConfigProperties(
|
||||
var generator: PseudonymGenerator = PseudonymGenerator.BUILDIN,
|
||||
val prefix: String = "UNKNOWN",
|
||||
) {
|
||||
companion object {
|
||||
@ -46,28 +50,78 @@ data class GPasConfigProperties(
|
||||
val target: String = "etl-processor",
|
||||
val username: String?,
|
||||
val password: String?,
|
||||
val sslCaLocation: String?,
|
||||
|
||||
) {
|
||||
) {
|
||||
companion object {
|
||||
const val NAME = "app.pseudonymize.gpas"
|
||||
}
|
||||
}
|
||||
|
||||
@ConfigurationProperties(GIcsConfigProperties.NAME)
|
||||
data class GIcsConfigProperties(
|
||||
/**
|
||||
* Base URL to gICS System
|
||||
*
|
||||
*/
|
||||
val uri: String?,
|
||||
val username: String?,
|
||||
val password: String?,
|
||||
|
||||
/**
|
||||
* If value is 'true' valid consent at processing time is mandatory for transmission of DNPM
|
||||
* files otherwise they will be flagged and skipped.
|
||||
* If value 'false' or missing consent status is assumed to be valid.
|
||||
*/
|
||||
val enabled: Boolean?,
|
||||
|
||||
/**
|
||||
* gICS specific system
|
||||
* **/
|
||||
val personIdentifierSystem: String =
|
||||
"https://ths-greifswald.de/fhir/gics/identifiers/Patienten-ID",
|
||||
|
||||
/**
|
||||
* Domain of broad consent resources
|
||||
**/
|
||||
val broadConsentDomainName: String = "MII",
|
||||
|
||||
/**
|
||||
* Domain of Modelvorhaben 64e consent resources
|
||||
**/
|
||||
val gnomDeConsentDomainName: String = "GenomDE_MV",
|
||||
|
||||
/**
|
||||
* Value to expect in case of positiv consent
|
||||
*/
|
||||
val policyCode: String = "2.16.840.1.113883.3.1937.777.24.5.3.6",
|
||||
|
||||
/**
|
||||
* Consent Policy which should be used for consent check
|
||||
*/
|
||||
val policySystem: String = "urn:oid:2.16.840.1.113883.3.1937.777.24.5.3"
|
||||
) {
|
||||
companion object {
|
||||
const val NAME = "app.consent.gics"
|
||||
}
|
||||
}
|
||||
|
||||
@ConfigurationProperties(RestTargetProperties.NAME)
|
||||
data class RestTargetProperties(
|
||||
val uri: String?,
|
||||
val username: String?,
|
||||
val password: String?,
|
||||
val isBwhc: Boolean = false,
|
||||
) {
|
||||
companion object {
|
||||
const val NAME = "app.rest"
|
||||
}
|
||||
}
|
||||
|
||||
@ConfigurationProperties(KafkaTargetProperties.NAME)
|
||||
data class KafkaTargetProperties(
|
||||
val topic: String = "etl-processor",
|
||||
val responseTopic: String = "${topic}_response",
|
||||
val groupId: String = "${topic}_group",
|
||||
@ConfigurationProperties(KafkaProperties.NAME)
|
||||
data class KafkaProperties(
|
||||
val inputTopic: String?,
|
||||
val outputTopic: String = "etl-processor",
|
||||
val outputResponseTopic: String = "${outputTopic}_response",
|
||||
val groupId: String = "${outputTopic}_group",
|
||||
val servers: String = ""
|
||||
) {
|
||||
companion object {
|
||||
@ -75,7 +129,26 @@ data class KafkaTargetProperties(
|
||||
}
|
||||
}
|
||||
|
||||
@ConfigurationProperties(SecurityConfigProperties.NAME)
|
||||
data class SecurityConfigProperties(
|
||||
val adminUser: String?,
|
||||
val adminPassword: String?,
|
||||
val enableTokens: Boolean = false,
|
||||
val enableOidc: Boolean = false,
|
||||
val defaultNewUserRole: Role = Role.USER
|
||||
) {
|
||||
companion object {
|
||||
const val NAME = "app.security"
|
||||
}
|
||||
}
|
||||
|
||||
enum class PseudonymGenerator {
|
||||
BUILDIN,
|
||||
GPAS
|
||||
}
|
||||
}
|
||||
|
||||
data class TransformationProperties(
|
||||
val path: String,
|
||||
val from: String,
|
||||
val to: String
|
||||
)
|
@ -1,7 +1,7 @@
|
||||
/*
|
||||
* This file is part of ETL-Processor
|
||||
*
|
||||
* Copyright (c) 2023 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
@ -20,34 +20,72 @@
|
||||
package dev.dnpm.etl.processor.config
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import dev.dnpm.etl.processor.monitoring.ReportService
|
||||
import dev.dnpm.etl.processor.consent.ConsentCheckFileBased
|
||||
import dev.dnpm.etl.processor.consent.ICheckConsent
|
||||
import dev.dnpm.etl.processor.consent.GicsConsentService
|
||||
import dev.dnpm.etl.processor.monitoring.*
|
||||
import dev.dnpm.etl.processor.pseudonym.AnonymizingGenerator
|
||||
import dev.dnpm.etl.processor.pseudonym.Generator
|
||||
import dev.dnpm.etl.processor.pseudonym.GpasPseudonymGenerator
|
||||
import dev.dnpm.etl.processor.pseudonym.PseudonymizeService
|
||||
import dev.dnpm.etl.processor.security.TokenRepository
|
||||
import dev.dnpm.etl.processor.security.TokenService
|
||||
import dev.dnpm.etl.processor.services.Transformation
|
||||
import dev.dnpm.etl.processor.services.TransformationService
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.data.jdbc.repository.config.AbstractJdbcConfiguration
|
||||
import org.springframework.retry.RetryCallback
|
||||
import org.springframework.retry.RetryContext
|
||||
import org.springframework.retry.RetryListener
|
||||
import org.springframework.retry.policy.SimpleRetryPolicy
|
||||
import org.springframework.retry.support.RetryTemplate
|
||||
import org.springframework.retry.support.RetryTemplateBuilder
|
||||
import org.springframework.scheduling.annotation.EnableScheduling
|
||||
import org.springframework.security.crypto.password.PasswordEncoder
|
||||
import org.springframework.security.provisioning.InMemoryUserDetailsManager
|
||||
import org.springframework.web.client.HttpClientErrorException
|
||||
import org.springframework.web.client.RestTemplate
|
||||
import reactor.core.publisher.Sinks
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.toJavaDuration
|
||||
|
||||
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(
|
||||
value = [
|
||||
AppConfigProperties::class,
|
||||
PseudonymizeConfigProperties::class,
|
||||
GPasConfigProperties::class
|
||||
GPasConfigProperties::class,
|
||||
GIcsConfigProperties::class
|
||||
]
|
||||
)
|
||||
@EnableScheduling
|
||||
class AppConfiguration {
|
||||
|
||||
@ConditionalOnProperty(value = ["app.pseudonymizer"], havingValue = "GPAS")
|
||||
private val logger = LoggerFactory.getLogger(AppConfiguration::class.java)
|
||||
|
||||
@Bean
|
||||
fun gpasPseudonymGenerator(configProperties: GPasConfigProperties): Generator {
|
||||
return GpasPseudonymGenerator(configProperties)
|
||||
fun restTemplate(): RestTemplate {
|
||||
return RestTemplate()
|
||||
}
|
||||
|
||||
@ConditionalOnProperty(value = ["app.pseudonymizer"], havingValue = "BUILDIN", matchIfMissing = true)
|
||||
@Bean
|
||||
fun appFhirConfig(): AppFhirConfig{
|
||||
return AppFhirConfig()
|
||||
}
|
||||
|
||||
@ConditionalOnProperty(value = ["app.pseudonymize.generator"], havingValue = "GPAS")
|
||||
@Bean
|
||||
fun gpasPseudonymGenerator(configProperties: GPasConfigProperties, retryTemplate: RetryTemplate, restTemplate: RestTemplate, appFhirConfig: AppFhirConfig): Generator {
|
||||
return GpasPseudonymGenerator(configProperties, retryTemplate, restTemplate, appFhirConfig)
|
||||
}
|
||||
|
||||
@ConditionalOnProperty(value = ["app.pseudonymize.generator"], havingValue = "BUILDIN", matchIfMissing = true)
|
||||
@Bean
|
||||
fun buildinPseudonymGenerator(): Generator {
|
||||
return AnonymizingGenerator()
|
||||
@ -62,8 +100,49 @@ class AppConfiguration {
|
||||
}
|
||||
|
||||
@Bean
|
||||
fun reportService(objectMapper: ObjectMapper): ReportService {
|
||||
return ReportService(objectMapper)
|
||||
fun reportService(): ReportService {
|
||||
return ReportService(getObjectMapper())
|
||||
}
|
||||
|
||||
@Bean
|
||||
fun getObjectMapper () : ObjectMapper{
|
||||
return JacksonConfig().objectMapper()
|
||||
}
|
||||
|
||||
@Bean
|
||||
fun transformationService(
|
||||
configProperties: AppConfigProperties
|
||||
): TransformationService {
|
||||
logger.info("Apply ${configProperties.transformations.size} transformation rules")
|
||||
return TransformationService(getObjectMapper(), configProperties.transformations.map {
|
||||
Transformation.of(it.path) from it.from to it.to
|
||||
})
|
||||
}
|
||||
|
||||
@Bean
|
||||
fun retryTemplate(configProperties: AppConfigProperties): RetryTemplate {
|
||||
return RetryTemplateBuilder()
|
||||
.notRetryOn(IllegalArgumentException::class.java)
|
||||
.notRetryOn(HttpClientErrorException.BadRequest::class.java)
|
||||
.notRetryOn(HttpClientErrorException.UnprocessableEntity::class.java)
|
||||
.exponentialBackoff(2.seconds.toJavaDuration(), 1.25, 5.seconds.toJavaDuration())
|
||||
.customPolicy(SimpleRetryPolicy(configProperties.maxRetryAttempts))
|
||||
.withListener(object : RetryListener {
|
||||
override fun <T : Any, E : Throwable> onError(
|
||||
context: RetryContext,
|
||||
callback: RetryCallback<T, E>,
|
||||
throwable: Throwable
|
||||
) {
|
||||
logger.warn("Error occured: {}. Retrying {}", throwable.message, context.retryCount)
|
||||
}
|
||||
})
|
||||
.build()
|
||||
}
|
||||
|
||||
@ConditionalOnProperty(value = ["app.security.enable-tokens"], havingValue = "true")
|
||||
@Bean
|
||||
fun tokenService(userDetailsManager: InMemoryUserDetailsManager, passwordEncoder: PasswordEncoder, tokenRepository: TokenRepository): TokenService {
|
||||
return TokenService(userDetailsManager, passwordEncoder, tokenRepository)
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ -71,5 +150,63 @@ class AppConfiguration {
|
||||
return Sinks.many().multicast().directBestEffort()
|
||||
}
|
||||
|
||||
@Bean
|
||||
fun connectionCheckUpdateProducer(): Sinks.Many<ConnectionCheckResult> {
|
||||
return Sinks.many().multicast().onBackpressureBuffer()
|
||||
}
|
||||
|
||||
@ConditionalOnProperty(value = ["app.pseudonymize.generator"], havingValue = "GPAS")
|
||||
@Bean
|
||||
fun gPasConnectionCheckService(
|
||||
restTemplate: RestTemplate,
|
||||
gPasConfigProperties: GPasConfigProperties,
|
||||
connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult>
|
||||
): ConnectionCheckService {
|
||||
return GPasConnectionCheckService(restTemplate, gPasConfigProperties, connectionCheckUpdateProducer)
|
||||
}
|
||||
|
||||
@ConditionalOnProperty(value = ["app.pseudonymizer"], havingValue = "GPAS")
|
||||
@ConditionalOnMissingBean
|
||||
@Bean
|
||||
fun gPasConnectionCheckServiceOnDeprecatedProperty(
|
||||
restTemplate: RestTemplate,
|
||||
gPasConfigProperties: GPasConfigProperties,
|
||||
connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult>
|
||||
): ConnectionCheckService {
|
||||
return GPasConnectionCheckService(restTemplate, gPasConfigProperties, connectionCheckUpdateProducer)
|
||||
}
|
||||
|
||||
@Bean
|
||||
fun jdbcConfiguration(): AbstractJdbcConfiguration {
|
||||
return AppJdbcConfiguration()
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnProperty(name = ["app.consent.gics.enabled"], havingValue = "true")
|
||||
fun gicsConsentService( gIcsConfigProperties: GIcsConfigProperties,
|
||||
retryTemplate: RetryTemplate, restTemplate: RestTemplate, appFhirConfig: AppFhirConfig): ICheckConsent {
|
||||
return GicsConsentService(
|
||||
gIcsConfigProperties,
|
||||
retryTemplate,
|
||||
restTemplate,
|
||||
appFhirConfig
|
||||
)
|
||||
}
|
||||
|
||||
@ConditionalOnProperty(name = ["app.consent.gics.enabled"], havingValue = "true")
|
||||
@Bean
|
||||
fun gIcsConnectionCheckService(
|
||||
restTemplate: RestTemplate,
|
||||
gIcsConfigProperties: GIcsConfigProperties,
|
||||
connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult>
|
||||
): ConnectionCheckService {
|
||||
return GIcsConnectionCheckService(restTemplate, gIcsConfigProperties, connectionCheckUpdateProducer)
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
fun constService(): ICheckConsent {
|
||||
return ConsentCheckFileBased()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -0,0 +1,16 @@
|
||||
package dev.dnpm.etl.processor.config
|
||||
|
||||
import ca.uhn.fhir.context.FhirContext
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
|
||||
|
||||
@Configuration
|
||||
class AppFhirConfig {
|
||||
private val fhirCtx: FhirContext = FhirContext.forR4()
|
||||
|
||||
@Bean
|
||||
fun fhirContext(): FhirContext {
|
||||
return fhirCtx
|
||||
}
|
||||
}
|
@ -0,0 +1,25 @@
|
||||
package dev.dnpm.etl.processor.config
|
||||
|
||||
import dev.dnpm.etl.processor.Fingerprint
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.core.convert.converter.Converter
|
||||
import org.springframework.data.jdbc.repository.config.AbstractJdbcConfiguration
|
||||
|
||||
@Configuration
|
||||
class AppJdbcConfiguration : AbstractJdbcConfiguration() {
|
||||
override fun userConverters(): MutableList<*> {
|
||||
return mutableListOf(StringToFingerprintConverter(), FingerprintToStringConverter())
|
||||
}
|
||||
}
|
||||
|
||||
class StringToFingerprintConverter : Converter<String, Fingerprint> {
|
||||
override fun convert(source: String): Fingerprint {
|
||||
return Fingerprint(source)
|
||||
}
|
||||
}
|
||||
|
||||
class FingerprintToStringConverter : Converter<Fingerprint, String> {
|
||||
override fun convert(source: Fingerprint): String {
|
||||
return source.value
|
||||
}
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
/*
|
||||
* This file is part of ETL-Processor
|
||||
*
|
||||
* Copyright (c) 2023 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
@ -20,8 +20,13 @@
|
||||
package dev.dnpm.etl.processor.config
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import dev.dnpm.etl.processor.input.KafkaInputListener
|
||||
import dev.dnpm.etl.processor.monitoring.ConnectionCheckResult
|
||||
import dev.dnpm.etl.processor.monitoring.ConnectionCheckService
|
||||
import dev.dnpm.etl.processor.monitoring.KafkaConnectionCheckService
|
||||
import dev.dnpm.etl.processor.output.KafkaMtbFileSender
|
||||
import dev.dnpm.etl.processor.output.MtbFileSender
|
||||
import dev.dnpm.etl.processor.services.RequestProcessor
|
||||
import dev.dnpm.etl.processor.services.kafka.KafkaResponseProcessor
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
|
||||
@ -35,12 +40,14 @@ import org.springframework.kafka.core.ConsumerFactory
|
||||
import org.springframework.kafka.core.KafkaTemplate
|
||||
import org.springframework.kafka.listener.ContainerProperties
|
||||
import org.springframework.kafka.listener.KafkaMessageListenerContainer
|
||||
import org.springframework.retry.support.RetryTemplate
|
||||
import reactor.core.publisher.Sinks
|
||||
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(
|
||||
value = [KafkaTargetProperties::class]
|
||||
value = [KafkaProperties::class]
|
||||
)
|
||||
@ConditionalOnProperty(value = ["app.kafka.topic", "app.kafka.servers"])
|
||||
@ConditionalOnProperty(value = ["app.kafka.servers"])
|
||||
@ConditionalOnMissingBean(MtbFileSender::class)
|
||||
@Order(-5)
|
||||
class AppKafkaConfiguration {
|
||||
@ -50,20 +57,21 @@ class AppKafkaConfiguration {
|
||||
@Bean
|
||||
fun kafkaMtbFileSender(
|
||||
kafkaTemplate: KafkaTemplate<String, String>,
|
||||
kafkaTargetProperties: KafkaTargetProperties,
|
||||
kafkaProperties: KafkaProperties,
|
||||
retryTemplate: RetryTemplate,
|
||||
objectMapper: ObjectMapper
|
||||
): MtbFileSender {
|
||||
logger.info("Selected 'KafkaMtbFileSender'")
|
||||
return KafkaMtbFileSender(kafkaTemplate, kafkaTargetProperties, objectMapper)
|
||||
return KafkaMtbFileSender(kafkaTemplate, kafkaProperties, retryTemplate, objectMapper)
|
||||
}
|
||||
|
||||
@Bean
|
||||
fun kafkaListenerContainer(
|
||||
fun kafkaResponseListenerContainer(
|
||||
consumerFactory: ConsumerFactory<String, String>,
|
||||
kafkaTargetProperties: KafkaTargetProperties,
|
||||
kafkaProperties: KafkaProperties,
|
||||
kafkaResponseProcessor: KafkaResponseProcessor
|
||||
): KafkaMessageListenerContainer<String, String> {
|
||||
val containerProperties = ContainerProperties(kafkaTargetProperties.responseTopic)
|
||||
val containerProperties = ContainerProperties(kafkaProperties.outputResponseTopic)
|
||||
containerProperties.messageListener = kafkaResponseProcessor
|
||||
return KafkaMessageListenerContainer(consumerFactory, containerProperties)
|
||||
}
|
||||
@ -76,4 +84,33 @@ class AppKafkaConfiguration {
|
||||
return KafkaResponseProcessor(applicationEventPublisher, objectMapper)
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnProperty(value = ["app.kafka.input-topic"])
|
||||
fun kafkaInputListenerContainer(
|
||||
consumerFactory: ConsumerFactory<String, String>,
|
||||
kafkaProperties: KafkaProperties,
|
||||
kafkaInputListener: KafkaInputListener
|
||||
): KafkaMessageListenerContainer<String, String> {
|
||||
val containerProperties = ContainerProperties(kafkaProperties.inputTopic)
|
||||
containerProperties.messageListener = kafkaInputListener
|
||||
return KafkaMessageListenerContainer(consumerFactory, containerProperties)
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnProperty(value = ["app.kafka.input-topic"])
|
||||
fun kafkaInputListener(
|
||||
requestProcessor: RequestProcessor,
|
||||
objectMapper: ObjectMapper
|
||||
): KafkaInputListener {
|
||||
return KafkaInputListener(requestProcessor, objectMapper)
|
||||
}
|
||||
|
||||
@Bean
|
||||
fun kafkaConnectionCheckService(
|
||||
consumerFactory: ConsumerFactory<String, String>,
|
||||
connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult>
|
||||
): ConnectionCheckService {
|
||||
return KafkaConnectionCheckService(consumerFactory.createConsumer(), connectionCheckUpdateProducer)
|
||||
}
|
||||
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
/*
|
||||
* This file is part of ETL-Processor
|
||||
*
|
||||
* Copyright (c) 2023 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
@ -19,8 +19,13 @@
|
||||
|
||||
package dev.dnpm.etl.processor.config
|
||||
|
||||
import dev.dnpm.etl.processor.monitoring.ConnectionCheckResult
|
||||
import dev.dnpm.etl.processor.monitoring.ConnectionCheckService
|
||||
import dev.dnpm.etl.processor.monitoring.ReportService
|
||||
import dev.dnpm.etl.processor.monitoring.RestConnectionCheckService
|
||||
import dev.dnpm.etl.processor.output.MtbFileSender
|
||||
import dev.dnpm.etl.processor.output.RestMtbFileSender
|
||||
import dev.dnpm.etl.processor.output.RestBwhcMtbFileSender
|
||||
import dev.dnpm.etl.processor.output.RestDipMtbFileSender
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
|
||||
@ -28,7 +33,9 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.core.annotation.Order
|
||||
import org.springframework.retry.support.RetryTemplate
|
||||
import org.springframework.web.client.RestTemplate
|
||||
import reactor.core.publisher.Sinks
|
||||
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(
|
||||
@ -44,14 +51,28 @@ class AppRestConfiguration {
|
||||
private val logger = LoggerFactory.getLogger(AppRestConfiguration::class.java)
|
||||
|
||||
@Bean
|
||||
fun restTemplate(): RestTemplate {
|
||||
return RestTemplate()
|
||||
fun restMtbFileSender(
|
||||
restTemplate: RestTemplate,
|
||||
restTargetProperties: RestTargetProperties,
|
||||
retryTemplate: RetryTemplate,
|
||||
reportService: ReportService,
|
||||
): MtbFileSender {
|
||||
if (restTargetProperties.isBwhc) {
|
||||
logger.info("Selected 'RestBwhcMtbFileSender'")
|
||||
return RestBwhcMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService)
|
||||
}
|
||||
|
||||
logger.info("Selected 'RestDipMtbFileSender'")
|
||||
return RestDipMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService)
|
||||
}
|
||||
|
||||
@Bean
|
||||
fun restMtbFileSender(restTemplate: RestTemplate, restTargetProperties: RestTargetProperties): MtbFileSender {
|
||||
logger.info("Selected 'RestMtbFileSender'")
|
||||
return RestMtbFileSender(restTemplate, restTargetProperties)
|
||||
fun restConnectionCheckService(
|
||||
restTemplate: RestTemplate,
|
||||
restTargetProperties: RestTargetProperties,
|
||||
connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult>
|
||||
): ConnectionCheckService {
|
||||
return RestConnectionCheckService(restTemplate, restTargetProperties, connectionCheckUpdateProducer)
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -0,0 +1,196 @@
|
||||
/*
|
||||
* This file is part of ETL-Processor
|
||||
*
|
||||
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.dnpm.etl.processor.config
|
||||
|
||||
import dev.dnpm.etl.processor.security.UserRole
|
||||
import dev.dnpm.etl.processor.security.UserRoleRepository
|
||||
import dev.dnpm.etl.processor.security.UserRoleService
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
|
||||
import org.springframework.security.config.annotation.web.invoke
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper
|
||||
import org.springframework.security.core.session.SessionRegistry
|
||||
import org.springframework.security.core.session.SessionRegistryImpl
|
||||
import org.springframework.security.core.userdetails.User
|
||||
import org.springframework.security.core.userdetails.UserDetails
|
||||
import org.springframework.security.crypto.factory.PasswordEncoderFactories
|
||||
import org.springframework.security.crypto.password.PasswordEncoder
|
||||
import org.springframework.security.oauth2.core.oidc.user.OidcUserAuthority
|
||||
import org.springframework.security.provisioning.InMemoryUserDetailsManager
|
||||
import org.springframework.security.web.SecurityFilterChain
|
||||
import java.util.*
|
||||
|
||||
|
||||
private const val LOGIN_PATH = "/login"
|
||||
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(
|
||||
value = [
|
||||
SecurityConfigProperties::class
|
||||
]
|
||||
)
|
||||
@ConditionalOnProperty(value = ["app.security.admin-user"])
|
||||
@EnableWebSecurity
|
||||
class AppSecurityConfiguration(
|
||||
private val securityConfigProperties: SecurityConfigProperties
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(AppSecurityConfiguration::class.java)
|
||||
|
||||
@Bean
|
||||
fun userDetailsService(passwordEncoder: PasswordEncoder): InMemoryUserDetailsManager {
|
||||
val adminUser = if (securityConfigProperties.adminUser.isNullOrBlank()) {
|
||||
logger.warn("Using random Admin User: admin")
|
||||
"admin"
|
||||
} else {
|
||||
securityConfigProperties.adminUser
|
||||
}
|
||||
|
||||
val adminPassword = if (securityConfigProperties.adminPassword.isNullOrBlank()) {
|
||||
val random = UUID.randomUUID().toString()
|
||||
logger.warn("Using random Admin Passwort: {}", random)
|
||||
passwordEncoder.encode(random)
|
||||
} else {
|
||||
securityConfigProperties.adminPassword
|
||||
}
|
||||
|
||||
val user: UserDetails = User.withUsername(adminUser)
|
||||
.password(adminPassword)
|
||||
.roles("ADMIN")
|
||||
.build()
|
||||
|
||||
return InMemoryUserDetailsManager(user)
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnProperty(value = ["app.security.enable-oidc"], havingValue = "true")
|
||||
fun filterChainOidc(
|
||||
http: HttpSecurity,
|
||||
passwordEncoder: PasswordEncoder,
|
||||
userRoleRepository: UserRoleRepository,
|
||||
sessionRegistry: SessionRegistry
|
||||
): SecurityFilterChain {
|
||||
http {
|
||||
authorizeHttpRequests {
|
||||
authorize("/configs/**", hasRole("ADMIN"))
|
||||
authorize("/mtbfile/**", hasAnyRole("MTBFILE", "ADMIN", "USER"))
|
||||
authorize("/report/**", hasAnyRole("ADMIN", "USER"))
|
||||
authorize("*.css", permitAll)
|
||||
authorize("*.ico", permitAll)
|
||||
authorize("*.jpeg", permitAll)
|
||||
authorize("*.js", permitAll)
|
||||
authorize("*.svg", permitAll)
|
||||
authorize("*.css", permitAll)
|
||||
authorize("/login/**", permitAll)
|
||||
authorize(anyRequest, permitAll)
|
||||
}
|
||||
httpBasic {
|
||||
realmName = "ETL-Processor"
|
||||
}
|
||||
formLogin {
|
||||
loginPage = LOGIN_PATH
|
||||
}
|
||||
oauth2Login {
|
||||
loginPage = LOGIN_PATH
|
||||
}
|
||||
sessionManagement {
|
||||
sessionConcurrency {
|
||||
maximumSessions = 1
|
||||
expiredUrl = "$LOGIN_PATH?expired"
|
||||
}
|
||||
sessionFixation {
|
||||
newSession()
|
||||
}
|
||||
}
|
||||
csrf { disable() }
|
||||
}
|
||||
return http.build()
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnProperty(value = ["app.security.enable-oidc"], havingValue = "true")
|
||||
fun grantedAuthoritiesMapper(
|
||||
userRoleRepository: UserRoleRepository,
|
||||
appSecurityConfigProperties: SecurityConfigProperties
|
||||
): GrantedAuthoritiesMapper {
|
||||
return GrantedAuthoritiesMapper { grantedAuthority ->
|
||||
grantedAuthority.filterIsInstance<OidcUserAuthority>()
|
||||
.onEach {
|
||||
val userRole = userRoleRepository.findByUsername(it.userInfo.preferredUsername)
|
||||
if (userRole.isEmpty) {
|
||||
userRoleRepository.save(
|
||||
UserRole(
|
||||
null,
|
||||
it.userInfo.preferredUsername,
|
||||
appSecurityConfigProperties.defaultNewUserRole
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
.map {
|
||||
val userRole = userRoleRepository.findByUsername(it.userInfo.preferredUsername)
|
||||
SimpleGrantedAuthority("ROLE_${userRole.get().role.toString().uppercase()}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnProperty(value = ["app.security.enable-oidc"], havingValue = "false", matchIfMissing = true)
|
||||
fun filterChain(http: HttpSecurity, passwordEncoder: PasswordEncoder): SecurityFilterChain {
|
||||
http {
|
||||
authorizeHttpRequests {
|
||||
authorize("/configs/**", hasRole("ADMIN"))
|
||||
authorize("/mtbfile/**", hasAnyRole("MTBFILE", "ADMIN"))
|
||||
authorize("/report/**", hasRole("ADMIN"))
|
||||
authorize(anyRequest, permitAll)
|
||||
}
|
||||
httpBasic {
|
||||
realmName = "ETL-Processor"
|
||||
}
|
||||
formLogin {
|
||||
loginPage = LOGIN_PATH
|
||||
}
|
||||
csrf { disable() }
|
||||
}
|
||||
return http.build()
|
||||
}
|
||||
|
||||
@Bean
|
||||
fun sessionRegistry(): SessionRegistry {
|
||||
return SessionRegistryImpl()
|
||||
}
|
||||
|
||||
@Bean
|
||||
fun passwordEncoder(): PasswordEncoder {
|
||||
return PasswordEncoderFactories.createDelegatingPasswordEncoder()
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnProperty(value = ["app.security.enable-oidc"], havingValue = "true")
|
||||
fun userRoleService(userRoleRepository: UserRoleRepository, sessionRegistry: SessionRegistry): UserRoleService {
|
||||
return UserRoleService(userRoleRepository, sessionRegistry)
|
||||
}
|
||||
}
|
@ -0,0 +1,12 @@
|
||||
package dev.dnpm.etl.processor.config
|
||||
|
||||
|
||||
import com.fasterxml.jackson.databind.module.SimpleModule
|
||||
import org.hl7.fhir.instance.model.api.IBaseResource
|
||||
|
||||
class FhirResourceModule : SimpleModule() {
|
||||
init {
|
||||
addSerializer(IBaseResource::class.java, IBaseResourceSerializer())
|
||||
addDeserializer(IBaseResource::class.java, IBaseResourceDeserializer())
|
||||
}
|
||||
}
|
@ -0,0 +1,20 @@
|
||||
package dev.dnpm.etl.processor.config
|
||||
|
||||
import ca.uhn.fhir.context.FhirContext
|
||||
import com.fasterxml.jackson.core.JsonParser
|
||||
|
||||
import com.fasterxml.jackson.databind.DeserializationContext
|
||||
import com.fasterxml.jackson.databind.JsonDeserializer
|
||||
import com.fasterxml.jackson.databind.JsonNode
|
||||
import org.hl7.fhir.instance.model.api.IBaseResource
|
||||
|
||||
class IBaseResourceDeserializer : JsonDeserializer<IBaseResource>() {
|
||||
override fun deserialize(p: JsonParser?, ctxt: DeserializationContext?): IBaseResource {
|
||||
val fhirContext = FhirContext.forR4()
|
||||
|
||||
val jsonNode = p?.readValueAsTree<JsonNode>()
|
||||
val json = jsonNode?.toString()
|
||||
|
||||
return fhirContext.newJsonParser().parseResource(json) as IBaseResource
|
||||
}
|
||||
}
|
@ -0,0 +1,19 @@
|
||||
package dev.dnpm.etl.processor.config
|
||||
|
||||
import ca.uhn.fhir.context.FhirContext
|
||||
import com.fasterxml.jackson.core.JsonGenerator
|
||||
import com.fasterxml.jackson.databind.JsonSerializer
|
||||
import com.fasterxml.jackson.databind.SerializerProvider
|
||||
import org.hl7.fhir.instance.model.api.IBaseResource
|
||||
|
||||
class IBaseResourceSerializer : JsonSerializer<IBaseResource>() {
|
||||
override fun serialize(
|
||||
value: IBaseResource,
|
||||
gen: JsonGenerator,
|
||||
serializers: SerializerProvider
|
||||
) {
|
||||
val fhirContext = FhirContext.forR4()
|
||||
val json = fhirContext.newJsonParser().encodeResourceToString(value)
|
||||
gen.writeRawValue(json)
|
||||
}
|
||||
}
|
@ -0,0 +1,19 @@
|
||||
package dev.dnpm.etl.processor.config
|
||||
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.databind.SerializationFeature
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
|
||||
|
||||
@Configuration
|
||||
class JacksonConfig {
|
||||
|
||||
@Bean
|
||||
fun objectMapper(): ObjectMapper =
|
||||
ObjectMapper()
|
||||
.registerModule(FhirResourceModule())
|
||||
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS).registerModule(
|
||||
JavaTimeModule()
|
||||
);
|
||||
}
|
@ -0,0 +1,96 @@
|
||||
/*
|
||||
* This file is part of ETL-Processor
|
||||
*
|
||||
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.dnpm.etl.processor.input
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import de.ukw.ccc.bwhc.dto.Consent
|
||||
import de.ukw.ccc.bwhc.dto.MtbFile
|
||||
import dev.dnpm.etl.processor.CustomMediaType
|
||||
import dev.dnpm.etl.processor.PatientId
|
||||
import dev.dnpm.etl.processor.RequestId
|
||||
import dev.dnpm.etl.processor.consent.TtpConsentStatus
|
||||
import dev.dnpm.etl.processor.services.RequestProcessor
|
||||
import org.apache.kafka.clients.consumer.ConsumerRecord
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.kafka.listener.MessageListener
|
||||
|
||||
class KafkaInputListener(
|
||||
private val requestProcessor: RequestProcessor,
|
||||
private val objectMapper: ObjectMapper
|
||||
) : MessageListener<String, String> {
|
||||
private val logger = LoggerFactory.getLogger(KafkaInputListener::class.java)
|
||||
|
||||
override fun onMessage(record: ConsumerRecord<String, String>) {
|
||||
when (guessMimeType(record)) {
|
||||
MediaType.APPLICATION_JSON_VALUE -> handleBwhcMessage(record)
|
||||
CustomMediaType.APPLICATION_VND_DNPM_V2_MTB_JSON_VALUE -> handleDnpmV2Message(record)
|
||||
else -> {
|
||||
/* ignore other messages */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun guessMimeType(record: ConsumerRecord<String, String>): String {
|
||||
if (record.headers().headers("contentType").toList().isEmpty()) {
|
||||
// Fallback if no contentType set (old behavior)
|
||||
return MediaType.APPLICATION_JSON_VALUE
|
||||
}
|
||||
|
||||
return record.headers().headers("contentType")?.firstOrNull()?.value().contentToString()
|
||||
}
|
||||
|
||||
private fun handleBwhcMessage(record: ConsumerRecord<String, String>) {
|
||||
val mtbFile = objectMapper.readValue(record.value(), MtbFile::class.java)
|
||||
val patientId = PatientId(mtbFile.patient.id)
|
||||
val firstRequestIdHeader = record.headers().headers("requestId")?.firstOrNull()
|
||||
val requestId = if (null != firstRequestIdHeader) {
|
||||
RequestId(String(firstRequestIdHeader.value()))
|
||||
} else {
|
||||
RequestId("")
|
||||
}
|
||||
|
||||
if (mtbFile.consent.status == Consent.Status.ACTIVE) {
|
||||
logger.debug("Accepted MTB File for processing")
|
||||
if (requestId.isBlank()) {
|
||||
requestProcessor.processMtbFile(mtbFile)
|
||||
} else {
|
||||
requestProcessor.processMtbFile(mtbFile, requestId)
|
||||
}
|
||||
} else {
|
||||
logger.debug("Accepted MTB File and process deletion")
|
||||
if (requestId.isBlank()) {
|
||||
requestProcessor.processDeletion(patientId, TtpConsentStatus.UNKNOWN_CHECK_FILE)
|
||||
} else {
|
||||
requestProcessor.processDeletion(
|
||||
patientId,
|
||||
requestId,
|
||||
TtpConsentStatus.UNKNOWN_CHECK_FILE
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDnpmV2Message(record: ConsumerRecord<String, String>) {
|
||||
// Do not handle DNPM-V2 for now
|
||||
logger.warn("Ignoring MTB File in DNPM V2 format: Not implemented yet")
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,94 @@
|
||||
/*
|
||||
* This file is part of ETL-Processor
|
||||
*
|
||||
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.dnpm.etl.processor.input
|
||||
|
||||
import de.ukw.ccc.bwhc.dto.Consent
|
||||
import de.ukw.ccc.bwhc.dto.MtbFile
|
||||
import dev.dnpm.etl.processor.CustomMediaType
|
||||
import dev.dnpm.etl.processor.PatientId
|
||||
import dev.dnpm.etl.processor.consent.ICheckConsent
|
||||
import dev.dnpm.etl.processor.consent.TtpConsentStatus
|
||||
import dev.dnpm.etl.processor.services.RequestProcessor
|
||||
import dev.pcvolkmer.mv64e.mtb.Mtb
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.*
|
||||
|
||||
@RestController
|
||||
@RequestMapping(path = ["mtbfile", "mtb"])
|
||||
class MtbFileRestController(
|
||||
private val requestProcessor: RequestProcessor, private val iCheckConsent: ICheckConsent
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(MtbFileRestController::class.java)
|
||||
|
||||
@GetMapping
|
||||
fun info(): ResponseEntity<String> {
|
||||
return ResponseEntity.ok("Test")
|
||||
}
|
||||
|
||||
@PostMapping(consumes = [MediaType.APPLICATION_JSON_VALUE])
|
||||
fun mtbFile(@RequestBody mtbFile: MtbFile): ResponseEntity<Unit> {
|
||||
val consentStatusBooleanPair = checkConsentStatus(mtbFile)
|
||||
val ttpConsentStatus = consentStatusBooleanPair.first
|
||||
val isConsentOK = consentStatusBooleanPair.second
|
||||
if (isConsentOK) {
|
||||
logger.debug("Accepted MTB File (bwHC V1) for processing")
|
||||
requestProcessor.processMtbFile(mtbFile)
|
||||
} else {
|
||||
|
||||
logger.debug("Accepted MTB File (bwHC V1) and process deletion")
|
||||
val patientId = PatientId(mtbFile.patient.id)
|
||||
requestProcessor.processDeletion(patientId, ttpConsentStatus)
|
||||
}
|
||||
return ResponseEntity.accepted().build()
|
||||
}
|
||||
|
||||
private fun checkConsentStatus(mtbFile: MtbFile): Pair<TtpConsentStatus, Boolean> {
|
||||
var ttpConsentStatus = iCheckConsent.getTtpConsentStatus(mtbFile.patient.id)
|
||||
|
||||
val isConsentOK =
|
||||
(ttpConsentStatus.equals(TtpConsentStatus.UNKNOWN_CHECK_FILE) && mtbFile.consent.status == Consent.Status.ACTIVE) ||
|
||||
ttpConsentStatus.equals(
|
||||
TtpConsentStatus.CONSENTED
|
||||
)
|
||||
if (ttpConsentStatus.equals(TtpConsentStatus.UNKNOWN_CHECK_FILE) && mtbFile.consent.status == Consent.Status.REJECTED) {
|
||||
// in case ttp check is disabled - we propagate rejected status anyway
|
||||
ttpConsentStatus = TtpConsentStatus.CONSENT_MISSING_OR_REJECTED
|
||||
}
|
||||
return Pair(ttpConsentStatus, isConsentOK)
|
||||
}
|
||||
|
||||
@PostMapping(consumes = [CustomMediaType.APPLICATION_VND_DNPM_V2_MTB_JSON_VALUE])
|
||||
fun mtbFile(@RequestBody mtbFile: Mtb): ResponseEntity<Unit> {
|
||||
logger.debug("Accepted MTB File (DNPM V2) for processing")
|
||||
requestProcessor.processMtbFile(mtbFile)
|
||||
return ResponseEntity.accepted().build()
|
||||
}
|
||||
|
||||
@DeleteMapping(path = ["{patientId}"])
|
||||
fun deleteData(@PathVariable patientId: String): ResponseEntity<Unit> {
|
||||
logger.debug("Accepted patient ID to process deletion")
|
||||
requestProcessor.processDeletion(PatientId(patientId), TtpConsentStatus.UNKNOWN_CHECK_FILE)
|
||||
return ResponseEntity.accepted().build()
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,270 @@
|
||||
/*
|
||||
* This file is part of ETL-Processor
|
||||
*
|
||||
* Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
|
||||
package dev.dnpm.etl.processor.monitoring
|
||||
|
||||
import dev.dnpm.etl.processor.config.GIcsConfigProperties
|
||||
import dev.dnpm.etl.processor.config.GPasConfigProperties
|
||||
import dev.dnpm.etl.processor.config.RestTargetProperties
|
||||
import jakarta.annotation.PostConstruct
|
||||
import org.apache.kafka.clients.consumer.Consumer
|
||||
import org.apache.kafka.common.errors.TimeoutException
|
||||
import org.springframework.beans.factory.annotation.Qualifier
|
||||
import org.springframework.http.*
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.web.client.RestTemplate
|
||||
import org.springframework.web.util.UriComponentsBuilder
|
||||
import reactor.core.publisher.Sinks
|
||||
import java.time.Instant
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.toJavaDuration
|
||||
|
||||
fun interface ConnectionCheckService {
|
||||
|
||||
fun connectionAvailable(): ConnectionCheckResult
|
||||
|
||||
}
|
||||
|
||||
interface OutputConnectionCheckService : ConnectionCheckService
|
||||
|
||||
sealed class ConnectionCheckResult {
|
||||
|
||||
abstract val available: Boolean
|
||||
|
||||
abstract val timestamp: Instant
|
||||
|
||||
abstract val lastChange: Instant
|
||||
|
||||
data class KafkaConnectionCheckResult(
|
||||
override val available: Boolean,
|
||||
override val timestamp: Instant,
|
||||
override val lastChange: Instant
|
||||
) : ConnectionCheckResult()
|
||||
|
||||
data class RestConnectionCheckResult(
|
||||
override val available: Boolean,
|
||||
override val timestamp: Instant,
|
||||
override val lastChange: Instant
|
||||
) : ConnectionCheckResult()
|
||||
|
||||
data class GPasConnectionCheckResult(
|
||||
override val available: Boolean,
|
||||
override val timestamp: Instant,
|
||||
override val lastChange: Instant
|
||||
) : ConnectionCheckResult()
|
||||
|
||||
data class GIcsConnectionCheckResult(
|
||||
override val available: Boolean,
|
||||
override val timestamp: Instant,
|
||||
override val lastChange: Instant
|
||||
) : ConnectionCheckResult()
|
||||
}
|
||||
|
||||
class KafkaConnectionCheckService(
|
||||
private val consumer: Consumer<String, String>,
|
||||
@Qualifier("connectionCheckUpdateProducer")
|
||||
private val connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult>
|
||||
) : OutputConnectionCheckService {
|
||||
|
||||
private var result = ConnectionCheckResult.KafkaConnectionCheckResult(false, Instant.now(), Instant.now())
|
||||
|
||||
@PostConstruct
|
||||
@Scheduled(cron = "0 * * * * *")
|
||||
fun check() {
|
||||
result = try {
|
||||
val available = null != consumer.listTopics(5.seconds.toJavaDuration())
|
||||
ConnectionCheckResult.KafkaConnectionCheckResult(
|
||||
available,
|
||||
Instant.now(),
|
||||
if (result.available == available) { result.lastChange } else { Instant.now() }
|
||||
)
|
||||
} catch (_: TimeoutException) {
|
||||
ConnectionCheckResult.KafkaConnectionCheckResult(
|
||||
false,
|
||||
Instant.now(),
|
||||
if (!result.available) { result.lastChange } else { Instant.now() }
|
||||
)
|
||||
}
|
||||
connectionCheckUpdateProducer.emitNext(
|
||||
result,
|
||||
Sinks.EmitFailureHandler.FAIL_FAST
|
||||
)
|
||||
}
|
||||
|
||||
override fun connectionAvailable(): ConnectionCheckResult.KafkaConnectionCheckResult {
|
||||
return this.result
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class RestConnectionCheckService(
|
||||
private val restTemplate: RestTemplate,
|
||||
private val restTargetProperties: RestTargetProperties,
|
||||
@Qualifier("connectionCheckUpdateProducer")
|
||||
private val connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult>
|
||||
) : OutputConnectionCheckService {
|
||||
|
||||
private var result = ConnectionCheckResult.RestConnectionCheckResult(false, Instant.now(), Instant.now())
|
||||
|
||||
@PostConstruct
|
||||
@Scheduled(cron = "0 * * * * *")
|
||||
fun check() {
|
||||
result = try {
|
||||
val available = restTemplate.getForEntity(
|
||||
if (restTargetProperties.isBwhc) {
|
||||
UriComponentsBuilder.fromUriString(restTargetProperties.uri.toString()).path("").toUriString()
|
||||
} else {
|
||||
UriComponentsBuilder.fromUriString(restTargetProperties.uri.toString())
|
||||
.pathSegment("mtb")
|
||||
.pathSegment("kaplan-meier")
|
||||
.pathSegment("config")
|
||||
.toUriString()
|
||||
},
|
||||
String::class.java
|
||||
).statusCode == HttpStatus.OK
|
||||
|
||||
ConnectionCheckResult.RestConnectionCheckResult(
|
||||
available,
|
||||
Instant.now(),
|
||||
if (result.available == available) { result.lastChange } else { Instant.now() }
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
ConnectionCheckResult.RestConnectionCheckResult(
|
||||
false,
|
||||
Instant.now(),
|
||||
if (!result.available) { result.lastChange } else { Instant.now() }
|
||||
)
|
||||
}
|
||||
connectionCheckUpdateProducer.emitNext(
|
||||
result,
|
||||
Sinks.EmitFailureHandler.FAIL_FAST
|
||||
)
|
||||
}
|
||||
|
||||
override fun connectionAvailable(): ConnectionCheckResult.RestConnectionCheckResult {
|
||||
return this.result
|
||||
}
|
||||
}
|
||||
|
||||
class GPasConnectionCheckService(
|
||||
private val restTemplate: RestTemplate,
|
||||
private val gPasConfigProperties: GPasConfigProperties,
|
||||
@Qualifier("connectionCheckUpdateProducer")
|
||||
private val connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult>
|
||||
) : ConnectionCheckService {
|
||||
|
||||
private var result = ConnectionCheckResult.GPasConnectionCheckResult(false, Instant.now(), Instant.now())
|
||||
|
||||
@PostConstruct
|
||||
@Scheduled(cron = "0 * * * * *")
|
||||
fun check() {
|
||||
result = try {
|
||||
val uri = UriComponentsBuilder.fromUriString(
|
||||
gPasConfigProperties.uri?.replace("/\$pseudonymizeAllowCreate", "/metadata").toString()
|
||||
).build().toUri()
|
||||
|
||||
val headers = HttpHeaders()
|
||||
headers.contentType = MediaType.APPLICATION_JSON
|
||||
if (!gPasConfigProperties.username.isNullOrBlank() && !gPasConfigProperties.password.isNullOrBlank()) {
|
||||
headers.setBasicAuth(gPasConfigProperties.username, gPasConfigProperties.password)
|
||||
}
|
||||
|
||||
val available = restTemplate.exchange(
|
||||
uri,
|
||||
HttpMethod.GET,
|
||||
HttpEntity<Void>(headers),
|
||||
Void::class.java
|
||||
).statusCode == HttpStatus.OK
|
||||
|
||||
ConnectionCheckResult.GPasConnectionCheckResult(
|
||||
available,
|
||||
Instant.now(),
|
||||
if (result.available == available) { result.lastChange } else { Instant.now() }
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
ConnectionCheckResult.GPasConnectionCheckResult(
|
||||
false,
|
||||
Instant.now(),
|
||||
if (!result.available) { result.lastChange } else { Instant.now() }
|
||||
)
|
||||
}
|
||||
connectionCheckUpdateProducer.emitNext(
|
||||
result,
|
||||
Sinks.EmitFailureHandler.FAIL_FAST
|
||||
)
|
||||
}
|
||||
|
||||
override fun connectionAvailable(): ConnectionCheckResult.GPasConnectionCheckResult {
|
||||
return this.result
|
||||
}
|
||||
}
|
||||
|
||||
class GIcsConnectionCheckService(
|
||||
private val restTemplate: RestTemplate,
|
||||
private val gIcsConfigProperties: GIcsConfigProperties,
|
||||
@Qualifier("connectionCheckUpdateProducer")
|
||||
private val connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult>
|
||||
) : ConnectionCheckService {
|
||||
|
||||
private var result = ConnectionCheckResult.GIcsConnectionCheckResult(false, Instant.now(), Instant.now())
|
||||
|
||||
@PostConstruct
|
||||
@Scheduled(cron = "0 * * * * *")
|
||||
fun check() {
|
||||
result = try {
|
||||
|
||||
val uri = UriComponentsBuilder.fromUriString(
|
||||
gIcsConfigProperties.uri.toString()).path("/metadata").build().toUri()
|
||||
|
||||
val headers = HttpHeaders()
|
||||
headers.contentType = MediaType.APPLICATION_JSON
|
||||
if (!gIcsConfigProperties.username.isNullOrBlank() && !gIcsConfigProperties.password.isNullOrBlank()) {
|
||||
headers.setBasicAuth(gIcsConfigProperties.username, gIcsConfigProperties.password)
|
||||
}
|
||||
|
||||
val available = restTemplate.exchange(
|
||||
uri,
|
||||
HttpMethod.GET,
|
||||
HttpEntity<Void>(headers),
|
||||
Void::class.java
|
||||
).statusCode == HttpStatus.OK
|
||||
|
||||
ConnectionCheckResult.GIcsConnectionCheckResult(
|
||||
available,
|
||||
Instant.now(),
|
||||
if (result.available == available) { result.lastChange } else { Instant.now() }
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
ConnectionCheckResult.GIcsConnectionCheckResult(
|
||||
false,
|
||||
Instant.now(),
|
||||
if (!result.available) { result.lastChange } else { Instant.now() }
|
||||
)
|
||||
}
|
||||
connectionCheckUpdateProducer.emitNext(
|
||||
result,
|
||||
Sinks.EmitFailureHandler.FAIL_FAST
|
||||
)
|
||||
}
|
||||
|
||||
override fun connectionAvailable(): ConnectionCheckResult.GIcsConnectionCheckResult {
|
||||
return this.result
|
||||
}
|
||||
}
|
@ -19,11 +19,15 @@
|
||||
|
||||
package dev.dnpm.etl.processor.monitoring
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonAlias
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
import com.fasterxml.jackson.annotation.JsonValue
|
||||
import com.fasterxml.jackson.core.JsonParseException
|
||||
import com.fasterxml.jackson.databind.JsonMappingException
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import dev.dnpm.etl.processor.monitoring.ReportService.Issue
|
||||
import dev.dnpm.etl.processor.monitoring.ReportService.Severity
|
||||
import java.util.Optional
|
||||
|
||||
class ReportService(
|
||||
private val objectMapper: ObjectMapper
|
||||
@ -34,7 +38,10 @@ class ReportService(
|
||||
return listOf()
|
||||
}
|
||||
return try {
|
||||
objectMapper.readValue(dataQualityReport, DataQualityReport::class.java).issues
|
||||
objectMapper
|
||||
.readValue(dataQualityReport, DataQualityReport::class.java)
|
||||
.issues
|
||||
.sortedBy { it.severity }
|
||||
} catch (e: Exception) {
|
||||
val otherIssue =
|
||||
Issue(Severity.ERROR, "Not parsable data quality report '$dataQualityReport'")
|
||||
@ -51,10 +58,25 @@ class ReportService(
|
||||
private data class DataQualityReport(val issues: List<Issue>)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class Issue(val severity: Severity, val message: String)
|
||||
data class Issue(
|
||||
val severity: Severity,
|
||||
@JsonAlias("details") val message: String,
|
||||
val path: Optional<String> = Optional.empty()
|
||||
)
|
||||
|
||||
enum class Severity(@JsonValue val value: String) {
|
||||
FATAL("fatal"),
|
||||
ERROR("error"),
|
||||
WARNING("warning"),
|
||||
INFO("info")
|
||||
}
|
||||
}
|
||||
|
||||
fun List<Issue>.asRequestStatus(): RequestStatus {
|
||||
val severity = this.minOfOrNull { it.severity }
|
||||
return when (severity) {
|
||||
Severity.FATAL, Severity.ERROR -> RequestStatus.ERROR
|
||||
Severity.WARNING -> RequestStatus.WARNING
|
||||
else -> RequestStatus.SUCCESS
|
||||
}
|
||||
}
|
@ -19,50 +19,79 @@
|
||||
|
||||
package dev.dnpm.etl.processor.monitoring
|
||||
|
||||
import dev.dnpm.etl.processor.*
|
||||
import org.springframework.data.annotation.Id
|
||||
import org.springframework.data.domain.Page
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.data.jdbc.repository.query.Query
|
||||
import org.springframework.data.relational.core.mapping.Column
|
||||
import org.springframework.data.relational.core.mapping.Embedded
|
||||
import org.springframework.data.relational.core.mapping.Table
|
||||
import org.springframework.data.repository.CrudRepository
|
||||
import org.springframework.data.repository.PagingAndSortingRepository
|
||||
import java.time.Instant
|
||||
import java.util.*
|
||||
|
||||
typealias RequestId = UUID
|
||||
|
||||
@Table("request")
|
||||
data class Request(
|
||||
@Id val id: Long? = null,
|
||||
val uuid: String = RequestId.randomUUID().toString(),
|
||||
val patientId: String,
|
||||
val pid: String,
|
||||
val fingerprint: String,
|
||||
val uuid: RequestId = randomRequestId(),
|
||||
val patientPseudonym: PatientPseudonym,
|
||||
val pid: PatientId,
|
||||
@Column("fingerprint")
|
||||
val fingerprint: Fingerprint,
|
||||
val type: RequestType,
|
||||
var status: RequestStatus,
|
||||
var processedAt: Instant = Instant.now(),
|
||||
@Embedded.Nullable var report: Report? = null
|
||||
)
|
||||
) {
|
||||
constructor(
|
||||
uuid: RequestId,
|
||||
patientPseudonym: PatientPseudonym,
|
||||
pid: PatientId,
|
||||
fingerprint: Fingerprint,
|
||||
type: RequestType,
|
||||
status: RequestStatus
|
||||
) :
|
||||
this(null, uuid, patientPseudonym, pid, fingerprint, type, status, Instant.now())
|
||||
|
||||
constructor(
|
||||
uuid: RequestId,
|
||||
patientPseudonym: PatientPseudonym,
|
||||
pid: PatientId,
|
||||
fingerprint: Fingerprint,
|
||||
type: RequestType,
|
||||
status: RequestStatus,
|
||||
processedAt: Instant
|
||||
) :
|
||||
this(null, uuid, patientPseudonym, pid, fingerprint, type, status, processedAt)
|
||||
}
|
||||
|
||||
@JvmRecord
|
||||
data class Report(
|
||||
val description: String,
|
||||
val dataQualityReport: String = ""
|
||||
)
|
||||
|
||||
@JvmRecord
|
||||
data class CountedState(
|
||||
val count: Int,
|
||||
val status: RequestStatus,
|
||||
)
|
||||
|
||||
interface RequestRepository : CrudRepository<Request, Long> {
|
||||
interface RequestRepository : CrudRepository<Request, Long>, PagingAndSortingRepository<Request, Long> {
|
||||
|
||||
fun findAllByPatientIdOrderByProcessedAtDesc(patientId: String): List<Request>
|
||||
fun findAllByPatientPseudonymOrderByProcessedAtDesc(patientId: PatientPseudonym): List<Request>
|
||||
|
||||
fun findByUuidEquals(uuid: String): Optional<Request>
|
||||
fun findByUuidEquals(uuid: RequestId): Optional<Request>
|
||||
|
||||
fun findRequestByPatientPseudonym(patientPseudonym: PatientPseudonym, pageable: Pageable): Page<Request>
|
||||
|
||||
@Query("SELECT count(*) AS count, status FROM request WHERE type = 'MTB_FILE' GROUP BY status ORDER BY status, count DESC;")
|
||||
fun countStates(): List<CountedState>
|
||||
|
||||
@Query("SELECT count(*) AS count, status FROM (" +
|
||||
"SELECT status, rank() OVER (PARTITION BY patient_id ORDER BY processed_at DESC) AS rank FROM request " +
|
||||
"SELECT status, rank() OVER (PARTITION BY patient_pseudonym ORDER BY processed_at DESC) AS rank FROM request " +
|
||||
"WHERE type = 'MTB_FILE' AND status NOT IN ('DUPLICATION') " +
|
||||
") rank WHERE rank = 1 GROUP BY status ORDER BY status, count DESC;")
|
||||
fun findPatientUniqueStates(): List<CountedState>
|
||||
@ -71,7 +100,7 @@ interface RequestRepository : CrudRepository<Request, Long> {
|
||||
fun countDeleteStates(): List<CountedState>
|
||||
|
||||
@Query("SELECT count(*) AS count, status FROM (" +
|
||||
"SELECT status, rank() OVER (PARTITION BY patient_id ORDER BY processed_at DESC) AS rank FROM request " +
|
||||
"SELECT status, rank() OVER (PARTITION BY patient_pseudonym ORDER BY processed_at DESC) AS rank FROM request " +
|
||||
"WHERE type = 'DELETE'" +
|
||||
") rank WHERE rank = 1 GROUP BY status ORDER BY status, count DESC;")
|
||||
fun findPatientUniqueDeleteStates(): List<CountedState>
|
||||
|
@ -24,5 +24,6 @@ enum class RequestStatus(val value: String) {
|
||||
WARNING("warning"),
|
||||
ERROR("error"),
|
||||
UNKNOWN("unknown"),
|
||||
DUPLICATION("duplication")
|
||||
DUPLICATION("duplication"),
|
||||
NO_CONSENT("no-consent")
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
/*
|
||||
* This file is part of ETL-Processor
|
||||
*
|
||||
* Copyright (c) 2023 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
@ -22,31 +22,44 @@ package dev.dnpm.etl.processor.output
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import de.ukw.ccc.bwhc.dto.Consent
|
||||
import de.ukw.ccc.bwhc.dto.MtbFile
|
||||
import dev.dnpm.etl.processor.config.KafkaTargetProperties
|
||||
import dev.dnpm.etl.processor.CustomMediaType
|
||||
import dev.dnpm.etl.processor.config.KafkaProperties
|
||||
import dev.dnpm.etl.processor.monitoring.RequestStatus
|
||||
import org.apache.kafka.clients.producer.ProducerRecord
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.kafka.core.KafkaTemplate
|
||||
import org.springframework.retry.support.RetryTemplate
|
||||
|
||||
class KafkaMtbFileSender(
|
||||
private val kafkaTemplate: KafkaTemplate<String, String>,
|
||||
private val kafkaTargetProperties: KafkaTargetProperties,
|
||||
private val kafkaProperties: KafkaProperties,
|
||||
private val retryTemplate: RetryTemplate,
|
||||
private val objectMapper: ObjectMapper
|
||||
) : MtbFileSender {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(KafkaMtbFileSender::class.java)
|
||||
|
||||
override fun send(request: MtbFileSender.MtbFileRequest): MtbFileSender.Response {
|
||||
override fun <T> send(request: MtbFileRequest<T>): MtbFileSender.Response {
|
||||
return try {
|
||||
val result = kafkaTemplate.send(
|
||||
kafkaTargetProperties.topic,
|
||||
key(request),
|
||||
objectMapper.writeValueAsString(request.mtbFile)
|
||||
)
|
||||
if (result.get() != null) {
|
||||
logger.debug("Sent file via KafkaMtbFileSender")
|
||||
MtbFileSender.Response(RequestStatus.UNKNOWN)
|
||||
} else {
|
||||
MtbFileSender.Response(RequestStatus.ERROR)
|
||||
return retryTemplate.execute<MtbFileSender.Response, Exception> {
|
||||
val record =
|
||||
ProducerRecord(kafkaProperties.outputTopic, key(request), objectMapper.writeValueAsString(request))
|
||||
when (request) {
|
||||
is BwhcV1MtbFileRequest -> record.headers()
|
||||
.add("contentType", MediaType.APPLICATION_JSON_VALUE.toByteArray())
|
||||
|
||||
is DnpmV2MtbFileRequest -> record.headers()
|
||||
.add("contentType", CustomMediaType.APPLICATION_VND_DNPM_V2_MTB_JSON_VALUE.toByteArray())
|
||||
}
|
||||
|
||||
val result = kafkaTemplate.send(record)
|
||||
if (result.get() != null) {
|
||||
logger.debug("Sent file via KafkaMtbFileSender")
|
||||
MtbFileSender.Response(RequestStatus.UNKNOWN)
|
||||
} else {
|
||||
MtbFileSender.Response(RequestStatus.ERROR)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("An error occurred sending to kafka", e)
|
||||
@ -54,28 +67,33 @@ class KafkaMtbFileSender(
|
||||
}
|
||||
}
|
||||
|
||||
override fun send(request: MtbFileSender.DeleteRequest): MtbFileSender.Response {
|
||||
override fun send(request: DeleteRequest): MtbFileSender.Response {
|
||||
val dummyMtbFile = MtbFile.builder()
|
||||
.withConsent(
|
||||
Consent.builder()
|
||||
.withPatient(request.patientId)
|
||||
.withPatient(request.patientId.value)
|
||||
.withStatus(Consent.Status.REJECTED)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
|
||||
return try {
|
||||
val result = kafkaTemplate.send(
|
||||
kafkaTargetProperties.topic,
|
||||
key(request),
|
||||
objectMapper.writeValueAsString(dummyMtbFile)
|
||||
)
|
||||
return retryTemplate.execute<MtbFileSender.Response, Exception> {
|
||||
val record =
|
||||
ProducerRecord(
|
||||
kafkaProperties.outputTopic,
|
||||
key(request),
|
||||
// Always use old BwhcV1FileRequest with Consent REJECT
|
||||
objectMapper.writeValueAsString(BwhcV1MtbFileRequest(request.requestId, dummyMtbFile))
|
||||
)
|
||||
|
||||
if (result.get() != null) {
|
||||
logger.debug("Sent deletion request via KafkaMtbFileSender")
|
||||
MtbFileSender.Response(RequestStatus.UNKNOWN)
|
||||
} else {
|
||||
MtbFileSender.Response(RequestStatus.ERROR)
|
||||
val result = kafkaTemplate.send(record)
|
||||
if (result.get() != null) {
|
||||
logger.debug("Sent deletion request via KafkaMtbFileSender")
|
||||
MtbFileSender.Response(RequestStatus.UNKNOWN)
|
||||
} else {
|
||||
MtbFileSender.Response(RequestStatus.ERROR)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("An error occurred sending to kafka", e)
|
||||
@ -83,14 +101,16 @@ class KafkaMtbFileSender(
|
||||
}
|
||||
}
|
||||
|
||||
private fun key(request: MtbFileSender.MtbFileRequest): String {
|
||||
return "{\"pid\": \"${request.mtbFile.patient.id}\", " +
|
||||
"\"eid\": \"${request.mtbFile.episode.id}\", " +
|
||||
"\"requestId\": \"${request.requestId}\"}"
|
||||
override fun endpoint(): String {
|
||||
return "${this.kafkaProperties.servers} (${this.kafkaProperties.outputTopic}/${this.kafkaProperties.outputResponseTopic})"
|
||||
}
|
||||
|
||||
private fun key(request: MtbFileSender.DeleteRequest): String {
|
||||
return "{\"pid\": \"${request.patientId}\", " +
|
||||
"\"requestId\": \"${request.requestId}\"}"
|
||||
private fun key(request: MtbRequest): String {
|
||||
return when (request) {
|
||||
is BwhcV1MtbFileRequest -> "{\"pid\": \"${request.content.patient.id}\"}"
|
||||
is DnpmV2MtbFileRequest -> "{\"pid\": \"${request.content.patient.id}\"}"
|
||||
is DeleteRequest -> "{\"pid\": \"${request.patientId.value}\"}"
|
||||
else -> throw IllegalArgumentException("Unsupported request type: ${request::class.simpleName}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -19,21 +19,17 @@
|
||||
|
||||
package dev.dnpm.etl.processor.output
|
||||
|
||||
import de.ukw.ccc.bwhc.dto.MtbFile
|
||||
import dev.dnpm.etl.processor.monitoring.RequestStatus
|
||||
import org.springframework.http.HttpStatusCode
|
||||
|
||||
interface MtbFileSender {
|
||||
fun send(request: MtbFileRequest): Response
|
||||
fun <T> send(request: MtbFileRequest<T>): Response
|
||||
|
||||
fun send(request: DeleteRequest): Response
|
||||
|
||||
fun endpoint(): String
|
||||
|
||||
data class Response(val status: RequestStatus, val body: String = "")
|
||||
|
||||
data class MtbFileRequest(val requestId: String, val mtbFile: MtbFile)
|
||||
|
||||
data class DeleteRequest(val requestId: String, val patientId: String)
|
||||
|
||||
}
|
||||
|
||||
fun Int.asRequestStatus(): RequestStatus {
|
||||
@ -47,4 +43,4 @@ fun Int.asRequestStatus(): RequestStatus {
|
||||
|
||||
fun HttpStatusCode.asRequestStatus(): RequestStatus {
|
||||
return this.value().asRequestStatus()
|
||||
}
|
||||
}
|
||||
|
59
src/main/kotlin/dev/dnpm/etl/processor/output/MtbRequest.kt
Normal file
@ -0,0 +1,59 @@
|
||||
/*
|
||||
* This file is part of ETL-Processor
|
||||
*
|
||||
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.dnpm.etl.processor.output
|
||||
|
||||
import de.ukw.ccc.bwhc.dto.MtbFile
|
||||
import dev.dnpm.etl.processor.PatientPseudonym
|
||||
import dev.dnpm.etl.processor.RequestId
|
||||
import dev.pcvolkmer.mv64e.mtb.Mtb
|
||||
|
||||
interface MtbRequest {
|
||||
val requestId: RequestId
|
||||
}
|
||||
|
||||
sealed interface MtbFileRequest<out T> : MtbRequest {
|
||||
override val requestId: RequestId
|
||||
val content: T
|
||||
|
||||
fun patientPseudonym(): PatientPseudonym
|
||||
}
|
||||
|
||||
data class BwhcV1MtbFileRequest(
|
||||
override val requestId: RequestId,
|
||||
override val content: MtbFile
|
||||
) : MtbFileRequest<MtbFile> {
|
||||
override fun patientPseudonym(): PatientPseudonym {
|
||||
return PatientPseudonym(content.patient.id)
|
||||
}
|
||||
}
|
||||
|
||||
data class DnpmV2MtbFileRequest(
|
||||
override val requestId: RequestId,
|
||||
override val content: Mtb
|
||||
) : MtbFileRequest<Mtb> {
|
||||
override fun patientPseudonym(): PatientPseudonym {
|
||||
return PatientPseudonym(content.patient.id)
|
||||
}
|
||||
}
|
||||
|
||||
data class DeleteRequest(
|
||||
override val requestId: RequestId,
|
||||
val patientId: PatientPseudonym
|
||||
) : MtbRequest
|
@ -0,0 +1,51 @@
|
||||
/*
|
||||
* This file is part of ETL-Processor
|
||||
*
|
||||
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.dnpm.etl.processor.output
|
||||
|
||||
import dev.dnpm.etl.processor.PatientPseudonym
|
||||
import dev.dnpm.etl.processor.config.RestTargetProperties
|
||||
import dev.dnpm.etl.processor.monitoring.ReportService
|
||||
import org.springframework.retry.support.RetryTemplate
|
||||
import org.springframework.web.client.RestTemplate
|
||||
import org.springframework.web.util.UriComponentsBuilder
|
||||
|
||||
class RestBwhcMtbFileSender(
|
||||
restTemplate: RestTemplate,
|
||||
private val restTargetProperties: RestTargetProperties,
|
||||
retryTemplate: RetryTemplate,
|
||||
reportService: ReportService,
|
||||
) : RestMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService) {
|
||||
|
||||
override fun sendUrl(): String {
|
||||
return UriComponentsBuilder
|
||||
.fromUriString(restTargetProperties.uri.toString())
|
||||
.pathSegment("MTBFile")
|
||||
.toUriString()
|
||||
}
|
||||
|
||||
override fun deleteUrl(patientId: PatientPseudonym): String {
|
||||
return UriComponentsBuilder
|
||||
.fromUriString(restTargetProperties.uri.toString())
|
||||
.pathSegment("Patient")
|
||||
.pathSegment(patientId.value)
|
||||
.toUriString()
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,55 @@
|
||||
/*
|
||||
* This file is part of ETL-Processor
|
||||
*
|
||||
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.dnpm.etl.processor.output
|
||||
|
||||
import dev.dnpm.etl.processor.PatientPseudonym
|
||||
import dev.dnpm.etl.processor.config.RestTargetProperties
|
||||
import dev.dnpm.etl.processor.monitoring.ReportService
|
||||
import org.springframework.retry.support.RetryTemplate
|
||||
import org.springframework.web.client.RestTemplate
|
||||
import org.springframework.web.util.UriComponentsBuilder
|
||||
|
||||
class RestDipMtbFileSender(
|
||||
restTemplate: RestTemplate,
|
||||
private val restTargetProperties: RestTargetProperties,
|
||||
retryTemplate: RetryTemplate,
|
||||
reportService: ReportService
|
||||
) : RestMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService) {
|
||||
|
||||
override fun sendUrl(): String {
|
||||
return UriComponentsBuilder
|
||||
.fromUriString(restTargetProperties.uri.toString())
|
||||
.pathSegment("mtb")
|
||||
.pathSegment("etl")
|
||||
.pathSegment("patient-record")
|
||||
.toUriString()
|
||||
}
|
||||
|
||||
override fun deleteUrl(patientId: PatientPseudonym): String {
|
||||
return UriComponentsBuilder
|
||||
.fromUriString(restTargetProperties.uri.toString())
|
||||
.pathSegment("mtb")
|
||||
.pathSegment("etl")
|
||||
.pathSegment("patient")
|
||||
.pathSegment(patientId.value)
|
||||
.toUriString()
|
||||
}
|
||||
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
/*
|
||||
* This file is part of ETL-Processor
|
||||
*
|
||||
* Copyright (c) 2023 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
@ -19,59 +19,77 @@
|
||||
|
||||
package dev.dnpm.etl.processor.output
|
||||
|
||||
import dev.dnpm.etl.processor.CustomMediaType
|
||||
import dev.dnpm.etl.processor.PatientPseudonym
|
||||
import dev.dnpm.etl.processor.config.RestTargetProperties
|
||||
import dev.dnpm.etl.processor.monitoring.ReportService
|
||||
import dev.dnpm.etl.processor.monitoring.RequestStatus
|
||||
import dev.dnpm.etl.processor.monitoring.asRequestStatus
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.http.HttpEntity
|
||||
import org.springframework.http.HttpHeaders
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.retry.support.RetryTemplate
|
||||
import org.springframework.web.client.RestClientException
|
||||
import org.springframework.web.client.RestClientResponseException
|
||||
import org.springframework.web.client.RestTemplate
|
||||
|
||||
class RestMtbFileSender(
|
||||
abstract class RestMtbFileSender(
|
||||
private val restTemplate: RestTemplate,
|
||||
private val restTargetProperties: RestTargetProperties
|
||||
private val restTargetProperties: RestTargetProperties,
|
||||
private val retryTemplate: RetryTemplate,
|
||||
private val reportService: ReportService
|
||||
) : MtbFileSender {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(RestMtbFileSender::class.java)
|
||||
|
||||
override fun send(request: MtbFileSender.MtbFileRequest): MtbFileSender.Response {
|
||||
abstract fun sendUrl(): String
|
||||
|
||||
abstract fun deleteUrl(patientId: PatientPseudonym): String
|
||||
|
||||
override fun <T> send(request: MtbFileRequest<T>): MtbFileSender.Response {
|
||||
try {
|
||||
val headers = HttpHeaders()
|
||||
headers.contentType = MediaType.APPLICATION_JSON
|
||||
val entityReq = HttpEntity(request.mtbFile, headers)
|
||||
val response = restTemplate.postForEntity(
|
||||
"${restTargetProperties.uri}/MTBFile",
|
||||
entityReq,
|
||||
String::class.java
|
||||
)
|
||||
if (!response.statusCode.is2xxSuccessful) {
|
||||
logger.warn("Error sending to remote system: {}", response.body)
|
||||
return MtbFileSender.Response(response.statusCode.asRequestStatus(), "Status-Code: ${response.statusCode.value()}")
|
||||
return retryTemplate.execute<MtbFileSender.Response, Exception> {
|
||||
val headers = getHttpHeaders(request)
|
||||
val entityReq = HttpEntity(request.content, headers)
|
||||
val response = restTemplate.postForEntity(
|
||||
sendUrl(),
|
||||
entityReq,
|
||||
String::class.java
|
||||
)
|
||||
if (!response.statusCode.is2xxSuccessful) {
|
||||
logger.warn("Error sending to remote system: {}", response.body)
|
||||
return@execute MtbFileSender.Response(
|
||||
reportService.deserialize(response.body).asRequestStatus(),
|
||||
"Status-Code: ${response.statusCode.value()}"
|
||||
)
|
||||
}
|
||||
logger.debug("Sent file via RestMtbFileSender")
|
||||
return@execute MtbFileSender.Response(reportService.deserialize(response.body).asRequestStatus(), response.body.orEmpty())
|
||||
}
|
||||
logger.debug("Sent file via RestMtbFileSender")
|
||||
return MtbFileSender.Response(response.statusCode.asRequestStatus(), response.body.orEmpty())
|
||||
} catch (e: IllegalArgumentException) {
|
||||
logger.error("Not a valid URI to export to: '{}'", restTargetProperties.uri!!)
|
||||
} catch (e: RestClientException) {
|
||||
} catch (e: RestClientResponseException) {
|
||||
logger.info(restTargetProperties.uri!!.toString())
|
||||
logger.error("Cannot send data to remote system", e)
|
||||
logger.error("Request data not accepted by remote system", e)
|
||||
return MtbFileSender.Response(reportService.deserialize(e.responseBodyAsString).asRequestStatus(), e.responseBodyAsString)
|
||||
}
|
||||
return MtbFileSender.Response(RequestStatus.ERROR, "Sonstiger Fehler bei der Übertragung")
|
||||
}
|
||||
|
||||
override fun send(request: MtbFileSender.DeleteRequest): MtbFileSender.Response {
|
||||
override fun send(request: DeleteRequest): MtbFileSender.Response {
|
||||
try {
|
||||
val headers = HttpHeaders()
|
||||
headers.contentType = MediaType.APPLICATION_JSON
|
||||
val entityReq = HttpEntity(null, headers)
|
||||
restTemplate.delete(
|
||||
"${restTargetProperties.uri}/Patient/${request.patientId}",
|
||||
entityReq,
|
||||
String::class.java
|
||||
)
|
||||
logger.debug("Sent file via RestMtbFileSender")
|
||||
return MtbFileSender.Response(RequestStatus.SUCCESS)
|
||||
return retryTemplate.execute<MtbFileSender.Response, Exception> {
|
||||
val headers = getHttpHeaders(request)
|
||||
val entityReq = HttpEntity(null, headers)
|
||||
restTemplate.delete(
|
||||
deleteUrl(request.patientId),
|
||||
entityReq,
|
||||
String::class.java
|
||||
)
|
||||
logger.debug("Sent file via RestMtbFileSender")
|
||||
return@execute MtbFileSender.Response(RequestStatus.SUCCESS)
|
||||
}
|
||||
} catch (e: IllegalArgumentException) {
|
||||
logger.error("Not a valid URI to export to: '{}'", restTargetProperties.uri!!)
|
||||
} catch (e: RestClientException) {
|
||||
@ -81,4 +99,26 @@ class RestMtbFileSender(
|
||||
return MtbFileSender.Response(RequestStatus.ERROR, "Sonstiger Fehler bei der Übertragung")
|
||||
}
|
||||
|
||||
}
|
||||
override fun endpoint(): String {
|
||||
return this.restTargetProperties.uri.orEmpty()
|
||||
}
|
||||
|
||||
private fun getHttpHeaders(request: MtbRequest): HttpHeaders {
|
||||
val username = restTargetProperties.username
|
||||
val password = restTargetProperties.password
|
||||
val headers = HttpHeaders()
|
||||
headers.contentType = when (request) {
|
||||
is BwhcV1MtbFileRequest -> MediaType.APPLICATION_JSON
|
||||
is DnpmV2MtbFileRequest -> CustomMediaType.APPLICATION_VND_DNPM_V2_MTB_JSON
|
||||
else -> MediaType.APPLICATION_JSON
|
||||
}
|
||||
|
||||
if (username.isNullOrBlank() || password.isNullOrBlank()) {
|
||||
return headers
|
||||
}
|
||||
|
||||
headers.setBasicAuth(username, password)
|
||||
return headers
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -19,6 +19,8 @@
|
||||
|
||||
package dev.dnpm.etl.processor.pseudonym
|
||||
|
||||
import dev.dnpm.etl.processor.PatientId
|
||||
import dev.dnpm.etl.processor.PatientPseudonym
|
||||
import dev.dnpm.etl.processor.config.PseudonymizeConfigProperties
|
||||
|
||||
class PseudonymizeService(
|
||||
@ -26,11 +28,15 @@ class PseudonymizeService(
|
||||
private val configProperties: PseudonymizeConfigProperties
|
||||
) {
|
||||
|
||||
fun patientPseudonym(patientId: String): String {
|
||||
fun patientPseudonym(patientId: PatientId): PatientPseudonym {
|
||||
return when (generator) {
|
||||
is GpasPseudonymGenerator -> generator.generate(patientId)
|
||||
else -> "${configProperties.prefix}_${generator.generate(patientId)}"
|
||||
is GpasPseudonymGenerator -> PatientPseudonym(generator.generate(patientId.value))
|
||||
else -> PatientPseudonym("${configProperties.prefix}_${generator.generate(patientId.value)}")
|
||||
}
|
||||
}
|
||||
|
||||
fun prefix(): String {
|
||||
return configProperties.prefix
|
||||
}
|
||||
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
/*
|
||||
* This file is part of ETL-Processor
|
||||
*
|
||||
* Copyright (c) 2023 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
* Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
@ -20,31 +20,308 @@
|
||||
package dev.dnpm.etl.processor.pseudonym
|
||||
|
||||
import de.ukw.ccc.bwhc.dto.MtbFile
|
||||
import dev.dnpm.etl.processor.PatientId
|
||||
import dev.pcvolkmer.mv64e.mtb.Mtb
|
||||
import org.apache.commons.codec.digest.DigestUtils
|
||||
import org.hl7.fhir.r4.model.Consent
|
||||
|
||||
/** Replaces patient ID with generated patient pseudonym
|
||||
*
|
||||
* @param pseudonymizeService The pseudonymizeService to be used
|
||||
* @return The MTB file containing patient pseudonymes
|
||||
*/
|
||||
infix fun MtbFile.pseudonymizeWith(pseudonymizeService: PseudonymizeService) {
|
||||
val patientPseudonym = pseudonymizeService.patientPseudonym(this.patient.id)
|
||||
val patientPseudonym = pseudonymizeService.patientPseudonym(PatientId(this.patient.id)).value
|
||||
|
||||
this.episode.patient = patientPseudonym
|
||||
this.carePlans.forEach { it.patient = patientPseudonym }
|
||||
this.episode?.patient = patientPseudonym
|
||||
this.carePlans?.forEach { it.patient = patientPseudonym }
|
||||
this.patient.id = patientPseudonym
|
||||
this.claims.forEach { it.patient = patientPseudonym }
|
||||
this.consent.patient = patientPseudonym
|
||||
this.claimResponses.forEach { it.patient = patientPseudonym }
|
||||
this.diagnoses.forEach { it.patient = patientPseudonym }
|
||||
this.ecogStatus.forEach { it.patient = patientPseudonym }
|
||||
this.familyMemberDiagnoses.forEach { it.patient = patientPseudonym }
|
||||
this.geneticCounsellingRequests.forEach { it.patient = patientPseudonym }
|
||||
this.histologyReevaluationRequests.forEach { it.patient = patientPseudonym }
|
||||
this.histologyReports.forEach { it.patient = patientPseudonym }
|
||||
this.lastGuidelineTherapies.forEach { it.patient = patientPseudonym }
|
||||
this.molecularPathologyFindings.forEach { it.patient = patientPseudonym }
|
||||
this.molecularTherapies.forEach { it.history.forEach { it.patient = patientPseudonym } }
|
||||
this.ngsReports.forEach { it.patient = patientPseudonym }
|
||||
this.previousGuidelineTherapies.forEach { it.patient = patientPseudonym }
|
||||
this.rebiopsyRequests.forEach { it.patient = patientPseudonym }
|
||||
this.recommendations.forEach { it.patient = patientPseudonym }
|
||||
this.recommendations.forEach { it.patient = patientPseudonym }
|
||||
this.responses.forEach { it.patient = patientPseudonym }
|
||||
this.specimens.forEach { it.patient = patientPseudonym }
|
||||
this.specimens.forEach { it.patient = patientPseudonym }
|
||||
}
|
||||
this.claims?.forEach { it.patient = patientPseudonym }
|
||||
this.consent?.patient = patientPseudonym
|
||||
this.claimResponses?.forEach { it.patient = patientPseudonym }
|
||||
this.diagnoses?.forEach { it.patient = patientPseudonym }
|
||||
this.ecogStatus?.forEach { it.patient = patientPseudonym }
|
||||
this.familyMemberDiagnoses?.forEach { it.patient = patientPseudonym }
|
||||
this.geneticCounsellingRequests?.forEach { it.patient = patientPseudonym }
|
||||
this.histologyReevaluationRequests?.forEach { it.patient = patientPseudonym }
|
||||
this.histologyReports?.forEach {
|
||||
it.patient = patientPseudonym
|
||||
it.tumorMorphology?.patient = patientPseudonym
|
||||
}
|
||||
this.lastGuidelineTherapies?.forEach { it.patient = patientPseudonym }
|
||||
this.molecularPathologyFindings?.forEach { it.patient = patientPseudonym }
|
||||
this.molecularTherapies?.forEach { molecularTherapy ->
|
||||
molecularTherapy.history.forEach {
|
||||
it.patient = patientPseudonym
|
||||
}
|
||||
}
|
||||
this.ngsReports?.forEach { it.patient = patientPseudonym }
|
||||
this.previousGuidelineTherapies?.forEach { it.patient = patientPseudonym }
|
||||
this.rebiopsyRequests?.forEach { it.patient = patientPseudonym }
|
||||
this.recommendations?.forEach { it.patient = patientPseudonym }
|
||||
this.responses?.forEach { it.patient = patientPseudonym }
|
||||
this.studyInclusionRequests?.forEach { it.patient = patientPseudonym }
|
||||
this.specimens?.forEach { it.patient = patientPseudonym }
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates new hash of content IDs with given prefix except for patient IDs
|
||||
*
|
||||
* @param pseudonymizeService The pseudonymizeService to be used
|
||||
* @return The MTB file containing rehashed content IDs
|
||||
*/
|
||||
infix fun MtbFile.anonymizeContentWith(pseudonymizeService: PseudonymizeService) {
|
||||
val prefix = pseudonymizeService.prefix()
|
||||
|
||||
fun anonymize(id: String): String {
|
||||
val hash = DigestUtils.sha256Hex("$prefix-$id").substring(0, 41).lowercase()
|
||||
return "$prefix$hash"
|
||||
}
|
||||
|
||||
this.episode?.apply {
|
||||
id = id?.let {
|
||||
anonymize(it)
|
||||
}
|
||||
}
|
||||
this.carePlans?.onEach { carePlan ->
|
||||
carePlan?.apply {
|
||||
id = id?.let { anonymize(it) }
|
||||
diagnosis = diagnosis?.let { anonymize(it) }
|
||||
geneticCounsellingRequest = geneticCounsellingRequest?.let { anonymize(it) }
|
||||
rebiopsyRequests = rebiopsyRequests.map { it?.let { anonymize(it) } }
|
||||
recommendations = recommendations.map { it?.let { anonymize(it) } }
|
||||
studyInclusionRequests = studyInclusionRequests.map { it?.let { anonymize(it) } }
|
||||
}
|
||||
}
|
||||
this.claims?.onEach { claim ->
|
||||
claim?.apply {
|
||||
id = id?.let { anonymize(it) }
|
||||
therapy = therapy?.let { anonymize(it) }
|
||||
}
|
||||
}
|
||||
this.claimResponses?.onEach { claimResponse ->
|
||||
claimResponse?.apply {
|
||||
id = id?.let { anonymize(it) }
|
||||
claim = claim?.let { anonymize(it) }
|
||||
}
|
||||
}
|
||||
this.consent?.apply {
|
||||
id = id?.let { anonymize(it) }
|
||||
}
|
||||
this.diagnoses?.onEach { diagnosis ->
|
||||
diagnosis?.apply {
|
||||
id = id?.let { anonymize(it) }
|
||||
histologyResults = histologyResults?.map { it?.let { anonymize(it) } }
|
||||
}
|
||||
}
|
||||
this.ecogStatus?.onEach { ecogStatus ->
|
||||
ecogStatus?.apply {
|
||||
id = id?.let { anonymize(it) }
|
||||
}
|
||||
}
|
||||
this.familyMemberDiagnoses?.onEach { familyMemberDiagnosis ->
|
||||
familyMemberDiagnosis?.apply {
|
||||
id = id?.let { anonymize(it) }
|
||||
}
|
||||
}
|
||||
this.geneticCounsellingRequests?.onEach { geneticCounsellingRequest ->
|
||||
geneticCounsellingRequest?.apply {
|
||||
id = id?.let { anonymize(it) }
|
||||
}
|
||||
}
|
||||
this.histologyReevaluationRequests?.onEach { histologyReevaluationRequest ->
|
||||
histologyReevaluationRequest?.apply {
|
||||
id = id?.let { anonymize(it) }
|
||||
specimen = specimen?.let { anonymize(it) }
|
||||
}
|
||||
}
|
||||
this.histologyReports?.onEach { histologyReport ->
|
||||
histologyReport?.apply {
|
||||
id = id?.let { anonymize(it) }
|
||||
specimen = specimen?.let { anonymize(it) }
|
||||
tumorMorphology?.apply {
|
||||
id = id?.let { anonymize(it) }
|
||||
specimen = specimen?.let { anonymize(it) }
|
||||
}
|
||||
tumorCellContent?.apply {
|
||||
id = id?.let { anonymize(it) }
|
||||
specimen = specimen?.let { anonymize(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
this.lastGuidelineTherapies?.onEach { lastGuidelineTherapy ->
|
||||
lastGuidelineTherapy?.apply {
|
||||
id = id?.let { anonymize(it) }
|
||||
diagnosis = diagnosis?.let { anonymize(it) }
|
||||
}
|
||||
}
|
||||
this.molecularPathologyFindings?.onEach { molecularPathologyFinding ->
|
||||
molecularPathologyFinding?.apply {
|
||||
id = id?.let { anonymize(it) }
|
||||
specimen = specimen?.let { anonymize(it) }
|
||||
}
|
||||
}
|
||||
this.molecularTherapies?.onEach { molecularTherapy ->
|
||||
molecularTherapy?.apply {
|
||||
history?.onEach { history ->
|
||||
history?.apply {
|
||||
id = id?.let { anonymize(it) }
|
||||
basedOn = basedOn?.let { anonymize(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
this.ngsReports?.onEach { ngsReport ->
|
||||
ngsReport?.apply {
|
||||
id = id?.let { anonymize(it) }
|
||||
specimen = specimen?.let { anonymize(it) }
|
||||
tumorCellContent?.apply {
|
||||
id = id?.let { anonymize(it) }
|
||||
specimen = specimen?.let { anonymize(it) }
|
||||
}
|
||||
simpleVariants?.onEach { simpleVariant ->
|
||||
simpleVariant?.apply {
|
||||
id = id?.let { anonymize(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
this.previousGuidelineTherapies?.onEach { previousGuidelineTherapy ->
|
||||
previousGuidelineTherapy?.apply {
|
||||
id = id?.let { anonymize(it) }
|
||||
diagnosis = diagnosis?.let { anonymize(it) }
|
||||
medication.forEach { medication ->
|
||||
medication?.apply {
|
||||
id = id?.let { anonymize(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
this.rebiopsyRequests?.onEach { rebiopsyRequest ->
|
||||
rebiopsyRequest?.apply {
|
||||
id = id?.let { anonymize(it) }
|
||||
specimen = specimen?.let { anonymize(it) }
|
||||
}
|
||||
}
|
||||
this.recommendations?.onEach { recommendation ->
|
||||
recommendation?.apply {
|
||||
id = id?.let { anonymize(it) }
|
||||
diagnosis = diagnosis?.let { anonymize(it) }
|
||||
ngsReport = ngsReport?.let { anonymize(it) }
|
||||
}
|
||||
}
|
||||
this.responses?.onEach { response ->
|
||||
response?.apply {
|
||||
id = id?.let { anonymize(it) }
|
||||
therapy = therapy?.let { anonymize(it) }
|
||||
}
|
||||
}
|
||||
this.studyInclusionRequests?.onEach { studyInclusionRequest ->
|
||||
studyInclusionRequest?.apply {
|
||||
id = id?.let { anonymize(it) }
|
||||
reason = reason?.let { anonymize(it) }
|
||||
}
|
||||
}
|
||||
this.specimens?.onEach { specimen ->
|
||||
specimen?.apply {
|
||||
id = id?.let { anonymize(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Replaces patient ID with generated patient pseudonym
|
||||
*
|
||||
* @since 0.11.0
|
||||
*
|
||||
* @param pseudonymizeService The pseudonymizeService to be used
|
||||
* @return The MTB file containing patient pseudonymes
|
||||
*/
|
||||
infix fun Mtb.pseudonymizeWith(pseudonymizeService: PseudonymizeService) {
|
||||
val patientPseudonym = pseudonymizeService.patientPseudonym(PatientId(this.patient.id)).value
|
||||
|
||||
this.episodesOfCare?.forEach { it.patient.id = patientPseudonym }
|
||||
this.carePlans?.forEach {
|
||||
it.patient.id = patientPseudonym
|
||||
it.rebiopsyRequests?.forEach { it.patient.id = patientPseudonym }
|
||||
it.histologyReevaluationRequests?.forEach { it.patient.id = patientPseudonym }
|
||||
it.medicationRecommendations.forEach { it.patient.id = patientPseudonym }
|
||||
it.studyEnrollmentRecommendations?.forEach { it.patient.id = patientPseudonym }
|
||||
it.procedureRecommendations?.forEach { it.patient.id = patientPseudonym }
|
||||
it.geneticCounselingRecommendation.patient.id = patientPseudonym
|
||||
}
|
||||
this.diagnoses?.forEach { it.patient.id = patientPseudonym }
|
||||
this.guidelineTherapies?.forEach { it.patient.id = patientPseudonym }
|
||||
this.guidelineProcedures?.forEach { it.patient.id = patientPseudonym }
|
||||
this.patient.id = patientPseudonym
|
||||
this.claims?.forEach { it.patient.id = patientPseudonym }
|
||||
this.claimResponses?.forEach { it.patient.id = patientPseudonym }
|
||||
this.diagnoses?.forEach { it.patient.id = patientPseudonym }
|
||||
this.histologyReports?.forEach {
|
||||
it.patient.id = patientPseudonym
|
||||
it.results.tumorMorphology?.patient?.id = patientPseudonym
|
||||
it.results.tumorCellContent?.patient?.id = patientPseudonym
|
||||
}
|
||||
this.ngsReports?.forEach {
|
||||
it.patient.id = patientPseudonym
|
||||
it.results.simpleVariants?.forEach { it.patient.id = patientPseudonym }
|
||||
it.results.copyNumberVariants?.forEach { it.patient.id = patientPseudonym }
|
||||
it.results.dnaFusions?.forEach { it.patient.id = patientPseudonym }
|
||||
it.results.rnaFusions?.forEach { it.patient.id = patientPseudonym }
|
||||
it.results.tumorCellContent?.patient?.id = patientPseudonym
|
||||
it.results.brcaness?.patient?.id = patientPseudonym
|
||||
it.results.tmb?.patient?.id = patientPseudonym
|
||||
it.results.hrdScore?.patient?.id = patientPseudonym
|
||||
}
|
||||
this.ihcReports?.forEach {
|
||||
it.patient.id = patientPseudonym
|
||||
it.results.msiMmr?.forEach { it.patient.id = patientPseudonym }
|
||||
it.results.proteinExpression?.forEach { it.patient.id = patientPseudonym }
|
||||
}
|
||||
this.responses?.forEach { it.patient.id = patientPseudonym }
|
||||
this.specimens?.forEach { it.patient.id = patientPseudonym }
|
||||
this.priorDiagnosticReports?.forEach { it.patient.id = patientPseudonym }
|
||||
this.performanceStatus?.forEach { it.patient.id = patientPseudonym }
|
||||
this.systemicTherapies?.forEach {
|
||||
it.history?.forEach {
|
||||
it.patient.id = patientPseudonym
|
||||
}
|
||||
}
|
||||
this.followUps?.forEach {
|
||||
it.patient.id = patientPseudonym
|
||||
}
|
||||
|
||||
// FIXME: MUST CREATE TESTCASE - NEEDS TESTING!!
|
||||
this.metadata?.researchConsents?.forEach { it -> {
|
||||
val consent = it as? Consent
|
||||
consent?.patient?.reference = "Patient/$patientPseudonym"
|
||||
consent?.patient?.display = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates new hash of content IDs with given prefix except for patient IDs
|
||||
*
|
||||
* @since 0.11.0
|
||||
*
|
||||
* @param pseudonymizeService The pseudonymizeService to be used
|
||||
* @return The MTB file containing rehashed content IDs
|
||||
*/
|
||||
infix fun Mtb.anonymizeContentWith(pseudonymizeService: PseudonymizeService) {
|
||||
val prefix = pseudonymizeService.prefix()
|
||||
|
||||
fun anonymize(id: String): String {
|
||||
val hash = DigestUtils.sha256Hex("$prefix-$id").substring(0, 41).lowercase()
|
||||
return "$prefix$hash"
|
||||
}
|
||||
|
||||
this.episodesOfCare?.forEach {
|
||||
it?.apply {
|
||||
id = id?.let {
|
||||
anonymize(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO all other properties
|
||||
}
|
||||
|
@ -0,0 +1,92 @@
|
||||
/*
|
||||
* This file is part of ETL-Processor
|
||||
*
|
||||
* Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.dnpm.etl.processor.security
|
||||
|
||||
import jakarta.annotation.PostConstruct
|
||||
import org.springframework.data.annotation.Id
|
||||
import org.springframework.data.relational.core.mapping.Table
|
||||
import org.springframework.data.repository.CrudRepository
|
||||
import org.springframework.data.repository.findByIdOrNull
|
||||
import org.springframework.security.core.userdetails.User
|
||||
import org.springframework.security.crypto.password.PasswordEncoder
|
||||
import org.springframework.security.provisioning.InMemoryUserDetailsManager
|
||||
import java.time.Instant
|
||||
import java.util.*
|
||||
|
||||
class TokenService(
|
||||
private val userDetailsManager: InMemoryUserDetailsManager,
|
||||
private val passwordEncoder: PasswordEncoder,
|
||||
private val tokenRepository: TokenRepository
|
||||
) {
|
||||
|
||||
@PostConstruct
|
||||
fun setup() {
|
||||
tokenRepository.findAll().forEach {
|
||||
userDetailsManager.createUser(
|
||||
User.withUsername(it.username)
|
||||
.password(it.password)
|
||||
.roles("MTBFILE")
|
||||
.build()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun addToken(name: String): Result<String> {
|
||||
val username = name.lowercase().replace("""[^a-z0-9]""".toRegex(), "")
|
||||
if (userDetailsManager.userExists(username)) {
|
||||
return Result.failure(RuntimeException("Cannot use token name"))
|
||||
}
|
||||
|
||||
val password = Base64.getEncoder().encodeToString(UUID.randomUUID().toString().encodeToByteArray())
|
||||
val encodedPassword = passwordEncoder.encode(password).toString()
|
||||
|
||||
userDetailsManager.createUser(
|
||||
User.withUsername(username)
|
||||
.password(encodedPassword)
|
||||
.roles("MTBFILE")
|
||||
.build()
|
||||
)
|
||||
|
||||
tokenRepository.save(Token(name = name, username = username, password = encodedPassword))
|
||||
|
||||
return Result.success("$username:$password")
|
||||
}
|
||||
|
||||
fun deleteToken(id: Long) {
|
||||
val token = tokenRepository.findByIdOrNull(id) ?: return
|
||||
userDetailsManager.deleteUser(token.username)
|
||||
tokenRepository.delete(token)
|
||||
}
|
||||
|
||||
fun findAll(): List<Token> {
|
||||
return tokenRepository.findAll().toList()
|
||||
}
|
||||
}
|
||||
|
||||
@Table("token")
|
||||
data class Token(
|
||||
@Id val id: Long? = null,
|
||||
val name: String,
|
||||
val username: String,
|
||||
val password: String,
|
||||
val createdAt: Instant = Instant.now()
|
||||
)
|
||||
|
||||
interface TokenRepository : CrudRepository<Token, Long>
|
45
src/main/kotlin/dev/dnpm/etl/processor/security/UserRole.kt
Normal file
@ -0,0 +1,45 @@
|
||||
/*
|
||||
* This file is part of ETL-Processor
|
||||
*
|
||||
* Copyright (C) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
package dev.dnpm.etl.processor.security
|
||||
|
||||
import org.springframework.data.annotation.Id
|
||||
import org.springframework.data.relational.core.mapping.Table
|
||||
import org.springframework.data.repository.CrudRepository
|
||||
import java.util.*
|
||||
|
||||
@Table("user_role")
|
||||
data class UserRole(
|
||||
@Id val id: Long? = null,
|
||||
val username: String,
|
||||
var role: Role = Role.GUEST
|
||||
)
|
||||
|
||||
enum class Role(val value: String) {
|
||||
GUEST("guest"),
|
||||
USER("user"),
|
||||
ADMIN("admin")
|
||||
}
|
||||
|
||||
interface UserRoleRepository : CrudRepository<UserRole, Long> {
|
||||
|
||||
fun findByUsername(username: String): Optional<UserRole>
|
||||
|
||||
}
|
@ -0,0 +1,58 @@
|
||||
/*
|
||||
* This file is part of ETL-Processor
|
||||
*
|
||||
* Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.dnpm.etl.processor.security
|
||||
|
||||
import org.springframework.data.repository.findByIdOrNull
|
||||
import org.springframework.security.core.session.SessionRegistry
|
||||
import org.springframework.security.oauth2.core.oidc.user.OidcUser
|
||||
|
||||
class UserRoleService(
|
||||
private val userRoleRepository: UserRoleRepository,
|
||||
private val sessionRegistry: SessionRegistry
|
||||
) {
|
||||
fun updateUserRole(id: Long, role: Role) {
|
||||
val userRole = userRoleRepository.findByIdOrNull(id) ?: return
|
||||
userRole.role = role
|
||||
userRoleRepository.save(userRole)
|
||||
expireSessionFor(userRole.username)
|
||||
}
|
||||
|
||||
fun deleteUserRole(id: Long) {
|
||||
val userRole = userRoleRepository.findByIdOrNull(id) ?: return
|
||||
userRoleRepository.delete(userRole)
|
||||
expireSessionFor(userRole.username)
|
||||
}
|
||||
|
||||
fun findAll(): List<UserRole> {
|
||||
return userRoleRepository.findAll().toList()
|
||||
}
|
||||
|
||||
private fun expireSessionFor(username: String) {
|
||||
sessionRegistry.allPrincipals
|
||||
.filterIsInstance<OidcUser>()
|
||||
.filter { it.preferredUsername == username }
|
||||
.flatMap {
|
||||
sessionRegistry.getAllSessions(it, true)
|
||||
}
|
||||
.onEach {
|
||||
it.expireNow()
|
||||
}
|
||||
}
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
/*
|
||||
* This file is part of ETL-Processor
|
||||
*
|
||||
* Copyright (c) 2023 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
@ -21,16 +21,29 @@ package dev.dnpm.etl.processor.services
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import de.ukw.ccc.bwhc.dto.MtbFile
|
||||
import dev.dnpm.etl.processor.*
|
||||
import dev.dnpm.etl.processor.config.AppConfigProperties
|
||||
import dev.dnpm.etl.processor.consent.GicsConsentService
|
||||
import dev.dnpm.etl.processor.consent.TtpConsentStatus
|
||||
import dev.dnpm.etl.processor.monitoring.Report
|
||||
import dev.dnpm.etl.processor.monitoring.Request
|
||||
import dev.dnpm.etl.processor.monitoring.RequestStatus
|
||||
import dev.dnpm.etl.processor.monitoring.RequestType
|
||||
import dev.dnpm.etl.processor.output.MtbFileSender
|
||||
import dev.dnpm.etl.processor.output.*
|
||||
import dev.dnpm.etl.processor.pseudonym.PseudonymizeService
|
||||
import dev.dnpm.etl.processor.pseudonym.anonymizeContentWith
|
||||
import dev.dnpm.etl.processor.pseudonym.pseudonymizeWith
|
||||
import dev.pcvolkmer.mv64e.mtb.ConsentProvision
|
||||
import dev.pcvolkmer.mv64e.mtb.ModelProjectConsent
|
||||
import dev.pcvolkmer.mv64e.mtb.ModelProjectConsentPurpose
|
||||
import dev.pcvolkmer.mv64e.mtb.Mtb
|
||||
import dev.pcvolkmer.mv64e.mtb.MvhMetadata
|
||||
import dev.pcvolkmer.mv64e.mtb.Provision
|
||||
import org.apache.commons.codec.binary.Base32
|
||||
import org.apache.commons.codec.digest.DigestUtils
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.hl7.fhir.instance.model.api.IBaseResource
|
||||
import org.hl7.fhir.r4.model.Bundle
|
||||
import org.hl7.fhir.r4.model.Consent
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.stereotype.Service
|
||||
import java.time.Instant
|
||||
@ -39,37 +52,127 @@ import java.util.*
|
||||
@Service
|
||||
class RequestProcessor(
|
||||
private val pseudonymizeService: PseudonymizeService,
|
||||
private val transformationService: TransformationService,
|
||||
private val sender: MtbFileSender,
|
||||
private val requestService: RequestService,
|
||||
private val objectMapper: ObjectMapper,
|
||||
private val applicationEventPublisher: ApplicationEventPublisher
|
||||
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||
private val appConfigProperties: AppConfigProperties,
|
||||
private val gicsConsentService: GicsConsentService?
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(RequestProcessor::class.java)
|
||||
|
||||
fun processMtbFile(mtbFile: MtbFile) {
|
||||
val requestId = UUID.randomUUID().toString()
|
||||
val pid = mtbFile.patient.id
|
||||
processMtbFile(mtbFile, randomRequestId())
|
||||
}
|
||||
|
||||
fun processMtbFile(mtbFile: MtbFile, requestId: RequestId) {
|
||||
val pid = PatientId(mtbFile.patient.id)
|
||||
mtbFile pseudonymizeWith pseudonymizeService
|
||||
mtbFile anonymizeContentWith pseudonymizeService
|
||||
val request = BwhcV1MtbFileRequest(requestId, transformationService.transform(mtbFile))
|
||||
saveAndSend(request, pid)
|
||||
}
|
||||
|
||||
val request = MtbFileSender.MtbFileRequest(requestId, mtbFile)
|
||||
fun processMtbFile(mtbFile: Mtb) {
|
||||
processMtbFile(mtbFile, randomRequestId())
|
||||
}
|
||||
|
||||
fun processMtbFile(mtbFile: Mtb, requestId: RequestId) {
|
||||
val pid = PatientId(mtbFile.patient.id)
|
||||
|
||||
addConsentToMtb(mtbFile)
|
||||
mtbFile pseudonymizeWith pseudonymizeService
|
||||
mtbFile anonymizeContentWith pseudonymizeService
|
||||
val request = DnpmV2MtbFileRequest(requestId, transformationService.transform(mtbFile))
|
||||
saveAndSend(request, pid)
|
||||
}
|
||||
|
||||
fun addConsentToMtb(mtbFile: Mtb) {
|
||||
if (gicsConsentService == null) return
|
||||
// init metadata if necessary
|
||||
if (mtbFile.metadata == null) {
|
||||
val mvhMetadata = MvhMetadata.builder().build();
|
||||
mtbFile.metadata = mvhMetadata
|
||||
if (mtbFile.metadata.researchConsents == null) {
|
||||
mtbFile.metadata.researchConsents = mutableListOf()
|
||||
}
|
||||
if (mtbFile.metadata.modelProjectConsent == null) {
|
||||
mtbFile.metadata.modelProjectConsent = ModelProjectConsent()
|
||||
mtbFile.metadata.modelProjectConsent.provisions = mutableListOf()
|
||||
}
|
||||
}
|
||||
|
||||
// fixme Date should be extracted from mtbFile
|
||||
val consentGnomeDe =
|
||||
gicsConsentService.getGenomDeConsent(mtbFile.patient.id, Date.from(Instant.now()))
|
||||
addGenomeDbProvisions(mtbFile, consentGnomeDe)
|
||||
|
||||
// fixme Date should be extracted from mtbFile
|
||||
val broadConsent =
|
||||
gicsConsentService.getBroadConsent(mtbFile.patient.id, Date.from(Instant.now()))
|
||||
embedBroadConsentResources(mtbFile, broadConsent)
|
||||
}
|
||||
|
||||
fun embedBroadConsentResources(
|
||||
mtbFile: Mtb,
|
||||
broadConsent: Bundle
|
||||
) {
|
||||
broadConsent.entry.forEach { it ->
|
||||
mtbFile.metadata.researchConsents.add(mapOf(it.resource.id to it as IBaseResource))
|
||||
}
|
||||
}
|
||||
|
||||
fun addGenomeDbProvisions(
|
||||
mtbFile: Mtb,
|
||||
consentGnomeDe: Bundle
|
||||
) {
|
||||
consentGnomeDe.entry.forEach { it ->
|
||||
{
|
||||
val consent = it.resource as Consent
|
||||
val provisionComponent = consent.provision.provision.firstOrNull()
|
||||
val provisionCode =
|
||||
provisionComponent?.code?.firstOrNull()?.coding?.firstOrNull()?.code
|
||||
var isValidCode = true
|
||||
if (provisionCode != null) {
|
||||
var modelProjectConsentPurpose: ModelProjectConsentPurpose =
|
||||
ModelProjectConsentPurpose.SEQUENCING
|
||||
if (provisionCode == "Teilnahme") {
|
||||
modelProjectConsentPurpose = ModelProjectConsentPurpose.SEQUENCING
|
||||
} else if (provisionCode == "Fallidentifizierung") {
|
||||
modelProjectConsentPurpose = ModelProjectConsentPurpose.CASE_IDENTIFICATION
|
||||
} else if (provisionCode == "Rekontaktierung") {
|
||||
modelProjectConsentPurpose = ModelProjectConsentPurpose.REIDENTIFICATION
|
||||
} else {
|
||||
isValidCode = false
|
||||
}
|
||||
if (isValidCode) mtbFile.metadata.modelProjectConsent.provisions.add(
|
||||
Provision.builder().type(
|
||||
ConsentProvision.forValue(provisionComponent.type.name)
|
||||
).date(provisionComponent.period.start).purpose(
|
||||
modelProjectConsentPurpose
|
||||
).build()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> saveAndSend(request: MtbFileRequest<T>, pid: PatientId) {
|
||||
requestService.save(
|
||||
Request(
|
||||
uuid = requestId,
|
||||
patientId = request.mtbFile.patient.id,
|
||||
pid = pid,
|
||||
fingerprint = fingerprint(request.mtbFile),
|
||||
status = RequestStatus.UNKNOWN,
|
||||
type = RequestType.MTB_FILE
|
||||
request.requestId,
|
||||
request.patientPseudonym(),
|
||||
pid,
|
||||
fingerprint(request),
|
||||
RequestType.MTB_FILE,
|
||||
RequestStatus.UNKNOWN
|
||||
)
|
||||
)
|
||||
|
||||
if (isDuplication(mtbFile)) {
|
||||
if (appConfigProperties.duplicationDetection && isDuplication(request)) {
|
||||
applicationEventPublisher.publishEvent(
|
||||
ResponseEvent(
|
||||
requestId,
|
||||
request.requestId,
|
||||
Instant.now(),
|
||||
RequestStatus.DUPLICATION
|
||||
)
|
||||
@ -81,45 +184,61 @@ class RequestProcessor(
|
||||
|
||||
applicationEventPublisher.publishEvent(
|
||||
ResponseEvent(
|
||||
requestId,
|
||||
request.requestId,
|
||||
Instant.now(),
|
||||
responseStatus.status,
|
||||
when (responseStatus.status) {
|
||||
RequestStatus.WARNING -> Optional.of(responseStatus.body)
|
||||
RequestStatus.ERROR, RequestStatus.WARNING -> Optional.of(responseStatus.body)
|
||||
else -> Optional.empty()
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun isDuplication(pseudonymizedMtbFile: MtbFile): Boolean {
|
||||
private fun <T> isDuplication(pseudonymizedMtbFileRequest: MtbFileRequest<T>): Boolean {
|
||||
val patientPseudonym = when (pseudonymizedMtbFileRequest) {
|
||||
is BwhcV1MtbFileRequest -> PatientPseudonym(pseudonymizedMtbFileRequest.content.patient.id)
|
||||
is DnpmV2MtbFileRequest -> PatientPseudonym(pseudonymizedMtbFileRequest.content.patient.id)
|
||||
}
|
||||
|
||||
val lastMtbFileRequestForPatient =
|
||||
requestService.lastMtbFileRequestForPatientPseudonym(pseudonymizedMtbFile.patient.id)
|
||||
val isLastRequestDeletion = requestService.isLastRequestWithKnownStatusDeletion(pseudonymizedMtbFile.patient.id)
|
||||
requestService.lastMtbFileRequestForPatientPseudonym(patientPseudonym)
|
||||
val isLastRequestDeletion =
|
||||
requestService.isLastRequestWithKnownStatusDeletion(patientPseudonym)
|
||||
|
||||
return null != lastMtbFileRequestForPatient
|
||||
&& !isLastRequestDeletion
|
||||
&& lastMtbFileRequestForPatient.fingerprint == fingerprint(pseudonymizedMtbFile)
|
||||
&& lastMtbFileRequestForPatient.fingerprint == fingerprint(
|
||||
pseudonymizedMtbFileRequest
|
||||
)
|
||||
}
|
||||
|
||||
fun processDeletion(patientId: String) {
|
||||
val requestId = UUID.randomUUID().toString()
|
||||
fun processDeletion(patientId: PatientId, isConsented: TtpConsentStatus) {
|
||||
processDeletion(patientId, randomRequestId(), isConsented)
|
||||
}
|
||||
|
||||
fun processDeletion(patientId: PatientId, requestId: RequestId, isConsented: TtpConsentStatus) {
|
||||
try {
|
||||
val patientPseudonym = pseudonymizeService.patientPseudonym(patientId)
|
||||
|
||||
val requestStatus: RequestStatus = when (isConsented) {
|
||||
TtpConsentStatus.CONSENT_MISSING_OR_REJECTED -> RequestStatus.NO_CONSENT
|
||||
TtpConsentStatus.FAILED_TO_ASK -> RequestStatus.ERROR
|
||||
TtpConsentStatus.CONSENTED, TtpConsentStatus.UNKNOWN_CHECK_FILE -> RequestStatus.UNKNOWN
|
||||
}
|
||||
|
||||
requestService.save(
|
||||
Request(
|
||||
uuid = requestId,
|
||||
patientId = patientPseudonym,
|
||||
pid = patientId,
|
||||
fingerprint = fingerprint(patientPseudonym),
|
||||
status = RequestStatus.UNKNOWN,
|
||||
type = RequestType.DELETE
|
||||
requestId,
|
||||
patientPseudonym,
|
||||
patientId,
|
||||
fingerprint(patientPseudonym.value),
|
||||
RequestType.DELETE,
|
||||
requestStatus
|
||||
)
|
||||
)
|
||||
|
||||
val responseStatus = sender.send(MtbFileSender.DeleteRequest(requestId, patientPseudonym))
|
||||
val responseStatus = sender.send(DeleteRequest(requestId, patientPseudonym))
|
||||
|
||||
applicationEventPublisher.publishEvent(
|
||||
ResponseEvent(
|
||||
@ -137,9 +256,9 @@ class RequestProcessor(
|
||||
requestService.save(
|
||||
Request(
|
||||
uuid = requestId,
|
||||
patientId = "???",
|
||||
patientPseudonym = emptyPatientPseudonym(),
|
||||
pid = patientId,
|
||||
fingerprint = "",
|
||||
fingerprint = Fingerprint.empty(),
|
||||
status = RequestStatus.ERROR,
|
||||
type = RequestType.DELETE,
|
||||
report = Report("Fehler bei der Pseudonymisierung")
|
||||
@ -148,14 +267,19 @@ class RequestProcessor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun fingerprint(mtbFile: MtbFile): String {
|
||||
return fingerprint(objectMapper.writeValueAsString(mtbFile))
|
||||
private fun <T> fingerprint(request: MtbFileRequest<T>): Fingerprint {
|
||||
return when (request) {
|
||||
is BwhcV1MtbFileRequest -> fingerprint(objectMapper.writeValueAsString(request.content))
|
||||
is DnpmV2MtbFileRequest -> fingerprint(objectMapper.writeValueAsString(request.content))
|
||||
}
|
||||
}
|
||||
|
||||
private fun fingerprint(s: String): String {
|
||||
return Base32().encodeAsString(DigestUtils.sha256(s))
|
||||
.replace("=", "")
|
||||
.lowercase()
|
||||
private fun fingerprint(s: String): Fingerprint {
|
||||
return Fingerprint(
|
||||
Base32().encodeAsString(DigestUtils.sha256(s))
|
||||
.replace("=", "")
|
||||
.lowercase()
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -19,11 +19,13 @@
|
||||
|
||||
package dev.dnpm.etl.processor.services
|
||||
|
||||
import dev.dnpm.etl.processor.monitoring.Request
|
||||
import dev.dnpm.etl.processor.monitoring.RequestRepository
|
||||
import dev.dnpm.etl.processor.monitoring.RequestStatus
|
||||
import dev.dnpm.etl.processor.monitoring.RequestType
|
||||
import dev.dnpm.etl.processor.PatientPseudonym
|
||||
import dev.dnpm.etl.processor.RequestId
|
||||
import dev.dnpm.etl.processor.monitoring.*
|
||||
import org.springframework.data.domain.Page
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.stereotype.Service
|
||||
import java.util.*
|
||||
|
||||
@Service
|
||||
class RequestService(
|
||||
@ -32,15 +34,32 @@ class RequestService(
|
||||
|
||||
fun save(request: Request) = requestRepository.save(request)
|
||||
|
||||
fun allRequestsByPatientPseudonym(patientPseudonym: String) = requestRepository
|
||||
.findAllByPatientIdOrderByProcessedAtDesc(patientPseudonym)
|
||||
fun findAll(): Iterable<Request> = requestRepository.findAll()
|
||||
|
||||
fun lastMtbFileRequestForPatientPseudonym(patientPseudonym: String) =
|
||||
fun findAll(pageable: Pageable): Page<Request> = requestRepository.findAll(pageable)
|
||||
|
||||
fun findByUuid(uuid: RequestId): Optional<Request> =
|
||||
requestRepository.findByUuidEquals(uuid)
|
||||
|
||||
fun findRequestByPatientId(patientPseudonym: PatientPseudonym, pageable: Pageable): Page<Request> = requestRepository.findRequestByPatientPseudonym(patientPseudonym, pageable)
|
||||
|
||||
fun allRequestsByPatientPseudonym(patientPseudonym: PatientPseudonym) = requestRepository
|
||||
.findAllByPatientPseudonymOrderByProcessedAtDesc(patientPseudonym)
|
||||
|
||||
fun lastMtbFileRequestForPatientPseudonym(patientPseudonym: PatientPseudonym) =
|
||||
Companion.lastMtbFileRequestForPatientPseudonym(allRequestsByPatientPseudonym(patientPseudonym))
|
||||
|
||||
fun isLastRequestWithKnownStatusDeletion(patientPseudonym: String) =
|
||||
fun isLastRequestWithKnownStatusDeletion(patientPseudonym: PatientPseudonym) =
|
||||
Companion.isLastRequestWithKnownStatusDeletion(allRequestsByPatientPseudonym(patientPseudonym))
|
||||
|
||||
fun countStates(): Iterable<CountedState> = requestRepository.countStates()
|
||||
|
||||
fun countDeleteStates(): Iterable<CountedState> = requestRepository.countDeleteStates()
|
||||
|
||||
fun findPatientUniqueStates(): List<CountedState> = requestRepository.findPatientUniqueStates()
|
||||
|
||||
fun findPatientUniqueDeleteStates(): List<CountedState> = requestRepository.findPatientUniqueDeleteStates()
|
||||
|
||||
companion object {
|
||||
|
||||
fun lastMtbFileRequestForPatientPseudonym(allRequests: List<Request>) = allRequests
|
||||
|
@ -19,9 +19,8 @@
|
||||
|
||||
package dev.dnpm.etl.processor.services
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import dev.dnpm.etl.processor.RequestId
|
||||
import dev.dnpm.etl.processor.monitoring.Report
|
||||
import dev.dnpm.etl.processor.monitoring.RequestRepository
|
||||
import dev.dnpm.etl.processor.monitoring.RequestStatus
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.event.EventListener
|
||||
@ -32,16 +31,15 @@ import java.util.*
|
||||
|
||||
@Service
|
||||
class ResponseProcessor(
|
||||
private val requestRepository: RequestRepository,
|
||||
private val statisticsUpdateProducer: Sinks.Many<Any>,
|
||||
private val objectMapper: ObjectMapper
|
||||
private val requestService: RequestService,
|
||||
private val statisticsUpdateProducer: Sinks.Many<Any>
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(ResponseProcessor::class.java)
|
||||
|
||||
@EventListener(classes = [ResponseEvent::class])
|
||||
fun handleResponseEvent(event: ResponseEvent) {
|
||||
requestRepository.findByUuidEquals(event.requestUuid).ifPresentOrElse({
|
||||
requestService.findByUuid(event.requestUuid).ifPresentOrElse({
|
||||
it.processedAt = event.timestamp
|
||||
it.status = event.status
|
||||
|
||||
@ -72,13 +70,19 @@ class ResponseProcessor(
|
||||
)
|
||||
}
|
||||
|
||||
RequestStatus.NO_CONSENT -> {
|
||||
it.report = Report(
|
||||
"Einwilligung Status fehlt, widerrufen oder ungeklärt."
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
logger.error("Cannot process response: Unknown response code!")
|
||||
logger.error("Cannot process response: Unknown response!")
|
||||
return@ifPresentOrElse
|
||||
}
|
||||
}
|
||||
|
||||
requestRepository.save(it)
|
||||
requestService.save(it)
|
||||
|
||||
statisticsUpdateProducer.emitNext("", Sinks.EmitFailureHandler.FAIL_FAST)
|
||||
}, {
|
||||
@ -89,7 +93,7 @@ class ResponseProcessor(
|
||||
}
|
||||
|
||||
data class ResponseEvent(
|
||||
val requestUuid: String,
|
||||
val requestUuid: RequestId,
|
||||
val timestamp: Instant,
|
||||
val status: RequestStatus,
|
||||
val body: Optional<String> = Optional.empty()
|
||||
|
@ -0,0 +1,96 @@
|
||||
/*
|
||||
* This file is part of ETL-Processor
|
||||
*
|
||||
* Copyright (c) 2023 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.dnpm.etl.processor.services
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.jayway.jsonpath.JsonPath
|
||||
import com.jayway.jsonpath.PathNotFoundException
|
||||
import de.ukw.ccc.bwhc.dto.MtbFile
|
||||
import dev.pcvolkmer.mv64e.mtb.Mtb
|
||||
|
||||
class TransformationService(private val objectMapper: ObjectMapper, private val transformations: List<Transformation>) {
|
||||
fun transform(mtbFile: MtbFile): MtbFile {
|
||||
val json = transform(objectMapper.writeValueAsString(mtbFile))
|
||||
return objectMapper.readValue(json, MtbFile::class.java)
|
||||
}
|
||||
|
||||
fun transform(mtbFile: Mtb): Mtb {
|
||||
val json = transform(objectMapper.writeValueAsString(mtbFile))
|
||||
return objectMapper.readValue(json, Mtb::class.java)
|
||||
}
|
||||
|
||||
private fun transform(content: String): String {
|
||||
var json = content
|
||||
|
||||
transformations.forEach { transformation ->
|
||||
val jsonPath = JsonPath.parse(json)
|
||||
|
||||
try {
|
||||
val before = transformation.path.substringBeforeLast(".")
|
||||
val last = transformation.path.substringAfterLast(".")
|
||||
|
||||
val existingValue = if (transformation.existingValue is Number) transformation.existingValue else transformation.existingValue.toString()
|
||||
val newValue = if (transformation.newValue is Number) transformation.newValue else transformation.newValue.toString()
|
||||
|
||||
jsonPath.set("$.$before.[?]$last", newValue, {
|
||||
it.item(HashMap::class.java)[last] == existingValue
|
||||
})
|
||||
} catch (e: PathNotFoundException) {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
json = jsonPath.jsonString()
|
||||
}
|
||||
|
||||
return json
|
||||
}
|
||||
|
||||
fun getTransformations(): List<Transformation> {
|
||||
return this.transformations
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class Transformation private constructor(val path: String) {
|
||||
|
||||
lateinit var existingValue: Any
|
||||
private set
|
||||
lateinit var newValue: Any
|
||||
private set
|
||||
|
||||
infix fun from(value: Any): Transformation {
|
||||
this.existingValue = value
|
||||
return this
|
||||
}
|
||||
|
||||
infix fun to(value: Any): Transformation {
|
||||
this.newValue = value
|
||||
return this
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun of(path: String): Transformation {
|
||||
return Transformation(path)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
@ -22,6 +22,7 @@ package dev.dnpm.etl.processor.services.kafka
|
||||
import com.fasterxml.jackson.annotation.JsonAlias
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import dev.dnpm.etl.processor.RequestId
|
||||
import dev.dnpm.etl.processor.monitoring.RequestStatus
|
||||
import dev.dnpm.etl.processor.output.asRequestStatus
|
||||
import dev.dnpm.etl.processor.services.ResponseEvent
|
||||
@ -41,50 +42,40 @@ class KafkaResponseProcessor(
|
||||
|
||||
override fun onMessage(data: ConsumerRecord<String, String>) {
|
||||
try {
|
||||
Optional.of(objectMapper.readValue(data.key(), ResponseKey::class.java))
|
||||
Optional.of(objectMapper.readValue(data.value(), ResponseBody::class.java))
|
||||
} catch (e: Exception) {
|
||||
logger.error("Cannot process Kafka response", e)
|
||||
Optional.empty()
|
||||
}.ifPresentOrElse({ responseKey ->
|
||||
val event = try {
|
||||
val responseBody = objectMapper.readValue(data.value(), ResponseBody::class.java)
|
||||
ResponseEvent(
|
||||
responseKey.requestId,
|
||||
Instant.ofEpochMilli(data.timestamp()),
|
||||
responseBody.statusCode.asRequestStatus(),
|
||||
when (responseBody.statusCode.asRequestStatus()) {
|
||||
RequestStatus.SUCCESS -> {
|
||||
Optional.empty()
|
||||
}
|
||||
|
||||
RequestStatus.WARNING, RequestStatus.ERROR -> {
|
||||
Optional.of(objectMapper.writeValueAsString(responseBody.statusBody))
|
||||
}
|
||||
|
||||
else -> {
|
||||
logger.error("Kafka response: Unknown response code!")
|
||||
Optional.empty()
|
||||
}
|
||||
}.ifPresentOrElse({ responseBody ->
|
||||
val event = ResponseEvent(
|
||||
RequestId(responseBody.requestId),
|
||||
Instant.ofEpochMilli(data.timestamp()),
|
||||
responseBody.statusCode.asRequestStatus(),
|
||||
when (responseBody.statusCode.asRequestStatus()) {
|
||||
RequestStatus.SUCCESS -> {
|
||||
Optional.empty()
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("Cannot process Kafka response", e)
|
||||
ResponseEvent(
|
||||
responseKey.requestId,
|
||||
Instant.ofEpochMilli(data.timestamp()),
|
||||
RequestStatus.ERROR,
|
||||
Optional.of("Cannot process Kafka response")
|
||||
)
|
||||
}
|
||||
|
||||
RequestStatus.WARNING, RequestStatus.ERROR -> {
|
||||
Optional.of(objectMapper.writeValueAsString(responseBody.statusBody))
|
||||
}
|
||||
|
||||
else -> {
|
||||
logger.error("Kafka response: Unknown response code '{}'!", responseBody.statusCode)
|
||||
Optional.empty()
|
||||
}
|
||||
}
|
||||
)
|
||||
eventPublisher.publishEvent(event)
|
||||
}, {
|
||||
logger.error("No response key in Kafka response")
|
||||
logger.error("No requestId in Kafka response")
|
||||
})
|
||||
}
|
||||
|
||||
data class ResponseKey(val requestId: String)
|
||||
|
||||
data class ResponseBody(
|
||||
@JsonProperty("status_code") @JsonAlias("status code") val statusCode: Int,
|
||||
@JsonProperty("status_body") val statusBody: Map<String, Any>
|
||||
@JsonProperty("request_id") @JsonAlias("requestId") val requestId: String,
|
||||
@JsonProperty("status_code") @JsonAlias("statusCode") val statusCode: Int,
|
||||
@JsonProperty("status_body") @JsonAlias("statusBody") val statusBody: Map<String, Any>
|
||||
)
|
||||
|
||||
}
|
63
src/main/kotlin/dev/dnpm/etl/processor/types.kt
Normal file
@ -0,0 +1,63 @@
|
||||
/*
|
||||
* This file is part of ETL-Processor
|
||||
*
|
||||
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.dnpm.etl.processor
|
||||
|
||||
import org.springframework.http.MediaType
|
||||
import java.util.*
|
||||
|
||||
class Fingerprint(val value: String) {
|
||||
override fun hashCode() = value.hashCode()
|
||||
|
||||
override fun equals(other: Any?) = other is Fingerprint && other.value == value
|
||||
|
||||
companion object {
|
||||
fun empty() = Fingerprint("")
|
||||
}
|
||||
}
|
||||
|
||||
@JvmInline
|
||||
value class RequestId(val value: String) {
|
||||
|
||||
fun isBlank() = value.isBlank()
|
||||
|
||||
}
|
||||
|
||||
fun randomRequestId() = RequestId(UUID.randomUUID().toString())
|
||||
|
||||
@JvmInline
|
||||
value class PatientId(val value: String)
|
||||
|
||||
@JvmInline
|
||||
value class PatientPseudonym(val value: String)
|
||||
|
||||
fun emptyPatientPseudonym() = PatientPseudonym("")
|
||||
|
||||
/**
|
||||
* Custom MediaTypes
|
||||
*
|
||||
* @since 0.11.0
|
||||
*/
|
||||
object CustomMediaType {
|
||||
val APPLICATION_VND_DNPM_V2_MTB_JSON = MediaType("application", "vnd.dnpm.v2.mtb+json")
|
||||
const val APPLICATION_VND_DNPM_V2_MTB_JSON_VALUE = "application/vnd.dnpm.v2.mtb+json"
|
||||
|
||||
val APPLICATION_VND_DNPM_V2_RD_JSON = MediaType("application", "vnd.dnpm.v2.rd+json")
|
||||
const val APPLICATION_VND_DNPM_V2_RD_JSON_VALUE = "application/vnd.dnpm.v2.rd+json"
|
||||
}
|
221
src/main/kotlin/dev/dnpm/etl/processor/web/ConfigController.kt
Normal file
@ -0,0 +1,221 @@
|
||||
/*
|
||||
* This file is part of ETL-Processor
|
||||
*
|
||||
* Copyright (c) 2023 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.dnpm.etl.processor.web
|
||||
|
||||
import dev.dnpm.etl.processor.monitoring.*
|
||||
import dev.dnpm.etl.processor.output.MtbFileSender
|
||||
import dev.dnpm.etl.processor.pseudonym.Generator
|
||||
import dev.dnpm.etl.processor.security.Role
|
||||
import dev.dnpm.etl.processor.security.UserRole
|
||||
import dev.dnpm.etl.processor.security.Token
|
||||
import dev.dnpm.etl.processor.security.TokenService
|
||||
import dev.dnpm.etl.processor.services.TransformationService
|
||||
import dev.dnpm.etl.processor.security.UserRoleService
|
||||
import org.springframework.beans.factory.annotation.Qualifier
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.http.codec.ServerSentEvent
|
||||
import org.springframework.stereotype.Controller
|
||||
import org.springframework.ui.Model
|
||||
import org.springframework.web.bind.annotation.*
|
||||
import reactor.core.publisher.Flux
|
||||
import reactor.core.publisher.Sinks
|
||||
|
||||
@Controller
|
||||
@RequestMapping(path = ["configs"])
|
||||
class ConfigController(
|
||||
@Qualifier("connectionCheckUpdateProducer")
|
||||
private val connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult>,
|
||||
private val transformationService: TransformationService,
|
||||
private val pseudonymGenerator: Generator,
|
||||
private val mtbFileSender: MtbFileSender,
|
||||
private val connectionCheckServices: List<ConnectionCheckService>,
|
||||
private val tokenService: TokenService?,
|
||||
private val userRoleService: UserRoleService?
|
||||
) {
|
||||
|
||||
@GetMapping
|
||||
fun index(model: Model): String {
|
||||
val outputConnectionAvailable =
|
||||
connectionCheckServices.filterIsInstance<OutputConnectionCheckService>().firstOrNull()?.connectionAvailable()
|
||||
|
||||
val gPasConnectionAvailable =
|
||||
connectionCheckServices.filterIsInstance<GPasConnectionCheckService>().firstOrNull()?.connectionAvailable()
|
||||
|
||||
val gIcsConnectionAvailable =
|
||||
connectionCheckServices.filterIsInstance<GIcsConnectionCheckService>().firstOrNull()?.connectionAvailable()
|
||||
|
||||
model.addAttribute("pseudonymGenerator", pseudonymGenerator.javaClass.simpleName)
|
||||
model.addAttribute("mtbFileSender", mtbFileSender.javaClass.simpleName)
|
||||
model.addAttribute("mtbFileEndpoint", mtbFileSender.endpoint())
|
||||
model.addAttribute("outputConnectionAvailable", outputConnectionAvailable)
|
||||
model.addAttribute("gPasConnectionAvailable", gPasConnectionAvailable)
|
||||
model.addAttribute("gIcsConnectionAvailable", gIcsConnectionAvailable)
|
||||
model.addAttribute("tokensEnabled", tokenService != null)
|
||||
if (tokenService != null) {
|
||||
model.addAttribute("tokens", tokenService.findAll())
|
||||
} else {
|
||||
model.addAttribute("tokens", emptyList<Token>())
|
||||
}
|
||||
model.addAttribute("transformations", transformationService.getTransformations())
|
||||
if (userRoleService != null) {
|
||||
model.addAttribute("userRolesEnabled", true)
|
||||
model.addAttribute("userRoles", userRoleService.findAll())
|
||||
} else {
|
||||
model.addAttribute("userRolesEnabled", false)
|
||||
model.addAttribute("userRoles", emptyList<UserRole>())
|
||||
}
|
||||
return "configs"
|
||||
}
|
||||
|
||||
@GetMapping(params = ["outputConnectionAvailable"])
|
||||
fun outputConnectionAvailable(model: Model): String {
|
||||
val outputConnectionAvailable =
|
||||
connectionCheckServices.filterIsInstance<OutputConnectionCheckService>().first().connectionAvailable()
|
||||
|
||||
model.addAttribute("mtbFileSender", mtbFileSender.javaClass.simpleName)
|
||||
model.addAttribute("mtbFileEndpoint", mtbFileSender.endpoint())
|
||||
model.addAttribute("outputConnectionAvailable", outputConnectionAvailable)
|
||||
if (tokenService != null) {
|
||||
model.addAttribute("tokensEnabled", true)
|
||||
model.addAttribute("tokens", tokenService.findAll())
|
||||
} else {
|
||||
model.addAttribute("tokens", listOf<Token>())
|
||||
}
|
||||
|
||||
return "configs/outputConnectionAvailable"
|
||||
}
|
||||
|
||||
@GetMapping(params = ["gPasConnectionAvailable"])
|
||||
fun gPasConnectionAvailable(model: Model): String {
|
||||
val gPasConnectionAvailable =
|
||||
connectionCheckServices.filterIsInstance<GPasConnectionCheckService>().firstOrNull()?.connectionAvailable()
|
||||
|
||||
model.addAttribute("mtbFileSender", mtbFileSender.javaClass.simpleName)
|
||||
model.addAttribute("mtbFileEndpoint", mtbFileSender.endpoint())
|
||||
model.addAttribute("gPasConnectionAvailable", gPasConnectionAvailable)
|
||||
if (tokenService != null) {
|
||||
model.addAttribute("tokensEnabled", true)
|
||||
model.addAttribute("tokens", tokenService.findAll())
|
||||
} else {
|
||||
model.addAttribute("tokens", listOf<Token>())
|
||||
}
|
||||
|
||||
return "configs/gPasConnectionAvailable"
|
||||
}
|
||||
|
||||
@GetMapping(params = ["gIcsConnectionAvailable"])
|
||||
fun gIcsConnectionAvailable(model: Model): String {
|
||||
val gIcsConnectionAvailable =
|
||||
connectionCheckServices.filterIsInstance<GIcsConnectionCheckService>().firstOrNull()?.connectionAvailable()
|
||||
|
||||
model.addAttribute("mtbFileSender", mtbFileSender.javaClass.simpleName)
|
||||
model.addAttribute("mtbFileEndpoint", mtbFileSender.endpoint())
|
||||
model.addAttribute("gIcsConnectionAvailable", gIcsConnectionAvailable)
|
||||
if (tokenService != null) {
|
||||
model.addAttribute("tokensEnabled", true)
|
||||
model.addAttribute("tokens", tokenService.findAll())
|
||||
} else {
|
||||
model.addAttribute("tokens", listOf<Token>())
|
||||
}
|
||||
|
||||
return "configs/gIcsConnectionAvailable"
|
||||
}
|
||||
|
||||
@PostMapping(path = ["tokens"])
|
||||
fun addToken(@ModelAttribute("name") name: String, model: Model): String {
|
||||
if (tokenService == null) {
|
||||
model.addAttribute("tokensEnabled", false)
|
||||
model.addAttribute("success", false)
|
||||
} else {
|
||||
model.addAttribute("tokensEnabled", true)
|
||||
val result = tokenService.addToken(name)
|
||||
result.onSuccess {
|
||||
model.addAttribute("newTokenValue", it)
|
||||
model.addAttribute("success", true)
|
||||
}
|
||||
result.onFailure {
|
||||
model.addAttribute("success", false)
|
||||
}
|
||||
model.addAttribute("tokens", tokenService.findAll())
|
||||
}
|
||||
|
||||
return "configs/tokens"
|
||||
}
|
||||
|
||||
@DeleteMapping(path = ["tokens/{id}"])
|
||||
fun deleteToken(@PathVariable id: Long, model: Model): String {
|
||||
if (tokenService != null) {
|
||||
tokenService.deleteToken(id)
|
||||
|
||||
model.addAttribute("tokensEnabled", true)
|
||||
model.addAttribute("tokens", tokenService.findAll())
|
||||
} else {
|
||||
model.addAttribute("tokensEnabled", false)
|
||||
model.addAttribute("tokens", listOf<Token>())
|
||||
}
|
||||
return "configs/tokens"
|
||||
}
|
||||
|
||||
@DeleteMapping(path = ["userroles/{id}"])
|
||||
fun deleteUserRole(@PathVariable id: Long, model: Model): String {
|
||||
if (userRoleService != null) {
|
||||
userRoleService.deleteUserRole(id)
|
||||
|
||||
model.addAttribute("userRolesEnabled", true)
|
||||
model.addAttribute("userRoles", userRoleService.findAll())
|
||||
} else {
|
||||
model.addAttribute("userRolesEnabled", false)
|
||||
model.addAttribute("userRoles", emptyList<UserRole>())
|
||||
}
|
||||
return "configs/userroles"
|
||||
}
|
||||
|
||||
@PutMapping(path = ["userroles/{id}"])
|
||||
fun updateUserRole(@PathVariable id: Long, @ModelAttribute("role") role: Role, model: Model): String {
|
||||
if (userRoleService != null) {
|
||||
userRoleService.updateUserRole(id, role)
|
||||
|
||||
model.addAttribute("userRolesEnabled", true)
|
||||
model.addAttribute("userRoles", userRoleService.findAll())
|
||||
} else {
|
||||
model.addAttribute("userRolesEnabled", false)
|
||||
model.addAttribute("userRoles", emptyList<UserRole>())
|
||||
}
|
||||
return "configs/userroles"
|
||||
}
|
||||
|
||||
@GetMapping(path = ["events"], produces = [MediaType.TEXT_EVENT_STREAM_VALUE])
|
||||
@ResponseBody
|
||||
fun events(): Flux<ServerSentEvent<Any>> {
|
||||
return connectionCheckUpdateProducer.asFlux().map {
|
||||
val event = when (it) {
|
||||
is ConnectionCheckResult.KafkaConnectionCheckResult -> "output-connection-check"
|
||||
is ConnectionCheckResult.RestConnectionCheckResult -> "output-connection-check"
|
||||
is ConnectionCheckResult.GPasConnectionCheckResult -> "gpas-connection-check"
|
||||
is ConnectionCheckResult.GIcsConnectionCheckResult -> "gics-connection-check"
|
||||
}
|
||||
|
||||
ServerSentEvent.builder<Any>()
|
||||
.event(event).id("none").data(it)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -20,9 +20,13 @@
|
||||
package dev.dnpm.etl.processor.web
|
||||
|
||||
import dev.dnpm.etl.processor.NotFoundException
|
||||
import dev.dnpm.etl.processor.PatientPseudonym
|
||||
import dev.dnpm.etl.processor.RequestId
|
||||
import dev.dnpm.etl.processor.monitoring.ReportService
|
||||
import dev.dnpm.etl.processor.monitoring.RequestId
|
||||
import dev.dnpm.etl.processor.monitoring.RequestRepository
|
||||
import dev.dnpm.etl.processor.services.RequestService
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.data.domain.Sort
|
||||
import org.springframework.data.web.PageableDefault
|
||||
import org.springframework.stereotype.Controller
|
||||
import org.springframework.ui.Model
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
@ -32,13 +36,29 @@ import org.springframework.web.bind.annotation.RequestMapping
|
||||
@Controller
|
||||
@RequestMapping(path = ["/"])
|
||||
class HomeController(
|
||||
private val requestRepository: RequestRepository,
|
||||
private val requestService: RequestService,
|
||||
private val reportService: ReportService
|
||||
) {
|
||||
|
||||
@GetMapping
|
||||
fun index(model: Model): String {
|
||||
val requests = requestRepository.findAll().sortedByDescending { it.processedAt }.take(25)
|
||||
fun index(
|
||||
@PageableDefault(page = 0, size = 20, sort = ["processedAt"], direction = Sort.Direction.DESC) pageable: Pageable,
|
||||
model: Model
|
||||
): String {
|
||||
val requests = requestService.findAll(pageable)
|
||||
model.addAttribute("requests", requests)
|
||||
|
||||
return "index"
|
||||
}
|
||||
|
||||
@GetMapping(path = ["patient/{patientPseudonym}"])
|
||||
fun byPatient(
|
||||
@PathVariable patientPseudonym: PatientPseudonym,
|
||||
@PageableDefault(page = 0, size = 20, sort = ["processedAt"], direction = Sort.Direction.DESC) pageable: Pageable,
|
||||
model: Model
|
||||
): String {
|
||||
val requests = requestService.findRequestByPatientId(patientPseudonym, pageable)
|
||||
model.addAttribute("patientPseudonym", patientPseudonym.value)
|
||||
model.addAttribute("requests", requests)
|
||||
|
||||
return "index"
|
||||
@ -46,7 +66,7 @@ class HomeController(
|
||||
|
||||
@GetMapping(path = ["/report/{id}"])
|
||||
fun report(@PathVariable id: RequestId, model: Model): String {
|
||||
val request = requestRepository.findByUuidEquals(id.toString()).orElse(null) ?: throw NotFoundException()
|
||||
val request = requestService.findByUuid(id).orElse(null) ?: throw NotFoundException()
|
||||
model.addAttribute("request", request)
|
||||
model.addAttribute("issues", reportService.deserialize(request.report?.dataQualityReport))
|
||||
|
||||
|
@ -0,0 +1,47 @@
|
||||
/*
|
||||
* This file is part of ETL-Processor
|
||||
*
|
||||
* Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.dnpm.etl.processor.web
|
||||
|
||||
import dev.dnpm.etl.processor.config.SecurityConfigProperties
|
||||
import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties
|
||||
import org.springframework.stereotype.Controller
|
||||
import org.springframework.ui.Model
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
|
||||
@Controller
|
||||
class LoginController(
|
||||
private val securityConfigProperties: SecurityConfigProperties?,
|
||||
private val oAuth2ClientProperties: OAuth2ClientProperties?
|
||||
) {
|
||||
|
||||
@GetMapping(path = ["/login"])
|
||||
fun login(model: Model): String {
|
||||
if (securityConfigProperties?.enableOidc == true) {
|
||||
model.addAttribute(
|
||||
"oidcLogins",
|
||||
oAuth2ClientProperties?.registration?.map { (key, value) -> Pair(key, value.clientName) }.orEmpty()
|
||||
)
|
||||
} else {
|
||||
model.addAttribute("oidcLogins", emptyList<Pair<String, String>>())
|
||||
}
|
||||
return "login"
|
||||
}
|
||||
|
||||
}
|
@ -1,55 +0,0 @@
|
||||
/*
|
||||
* This file is part of ETL-Processor
|
||||
*
|
||||
* Copyright (c) 2023 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.dnpm.etl.processor.web
|
||||
|
||||
import de.ukw.ccc.bwhc.dto.Consent
|
||||
import de.ukw.ccc.bwhc.dto.MtbFile
|
||||
import dev.dnpm.etl.processor.services.RequestProcessor
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.*
|
||||
|
||||
@RestController
|
||||
class MtbFileRestController(
|
||||
private val requestProcessor: RequestProcessor,
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(MtbFileRestController::class.java)
|
||||
|
||||
@PostMapping(path = ["/mtbfile"])
|
||||
fun mtbFile(@RequestBody mtbFile: MtbFile): ResponseEntity<Void> {
|
||||
if (mtbFile.consent.status == Consent.Status.ACTIVE) {
|
||||
logger.debug("Accepted MTB File for processing")
|
||||
requestProcessor.processMtbFile(mtbFile)
|
||||
} else {
|
||||
logger.debug("Accepted MTB File and process deletion")
|
||||
requestProcessor.processDeletion(mtbFile.patient.id)
|
||||
}
|
||||
return ResponseEntity.accepted().build()
|
||||
}
|
||||
|
||||
@DeleteMapping(path = ["/mtbfile/{patientId}"])
|
||||
fun deleteData(@PathVariable patientId: String): ResponseEntity<Void> {
|
||||
logger.debug("Accepted patient ID to process deletion")
|
||||
requestProcessor.processDeletion(patientId)
|
||||
return ResponseEntity.accepted().build()
|
||||
}
|
||||
|
||||
}
|
@ -19,9 +19,10 @@
|
||||
|
||||
package dev.dnpm.etl.processor.web
|
||||
|
||||
import dev.dnpm.etl.processor.monitoring.RequestRepository
|
||||
import dev.dnpm.etl.processor.monitoring.RequestStatus
|
||||
import dev.dnpm.etl.processor.monitoring.RequestType
|
||||
import dev.dnpm.etl.processor.services.RequestService
|
||||
import org.springframework.beans.factory.annotation.Qualifier
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.http.codec.ServerSentEvent
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
@ -38,16 +39,17 @@ import java.time.temporal.ChronoUnit
|
||||
@RestController
|
||||
@RequestMapping(path = ["/statistics"])
|
||||
class StatisticsRestController(
|
||||
@Qualifier("statisticsUpdateProducer")
|
||||
private val statisticsUpdateProducer: Sinks.Many<Any>,
|
||||
private val requestRepository: RequestRepository
|
||||
private val requestService: RequestService
|
||||
) {
|
||||
|
||||
@GetMapping(path = ["requeststates"])
|
||||
fun requestStates(@RequestParam(required = false, defaultValue = "false") delete: Boolean): List<NameValue> {
|
||||
val states = if (delete) {
|
||||
requestRepository.countDeleteStates()
|
||||
requestService.countDeleteStates()
|
||||
} else {
|
||||
requestRepository.countStates()
|
||||
requestService.countStates()
|
||||
}
|
||||
|
||||
return states
|
||||
@ -77,15 +79,15 @@ class StatisticsRestController(
|
||||
}
|
||||
|
||||
val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd").withZone(ZoneId.of("Europe/Berlin"))
|
||||
val data = requestRepository.findAll()
|
||||
val data = requestService.findAll()
|
||||
.filter { it.type == requestType }
|
||||
.filter { it.processedAt.isAfter(Instant.now().minus(30, ChronoUnit.DAYS)) }
|
||||
.groupBy { formatter.format(it.processedAt) }
|
||||
.map {
|
||||
val requestList = it.value
|
||||
.groupBy { it.status }
|
||||
.map {
|
||||
Pair(it.key, it.value.size)
|
||||
.groupBy { request -> request.status }
|
||||
.map { request ->
|
||||
Pair(request.key, request.value.size)
|
||||
}
|
||||
.toMap()
|
||||
Pair(
|
||||
@ -113,9 +115,9 @@ class StatisticsRestController(
|
||||
@GetMapping(path = ["requestpatientstates"])
|
||||
fun requestPatientStates(@RequestParam(required = false, defaultValue = "false") delete: Boolean): List<NameValue> {
|
||||
val states = if (delete) {
|
||||
requestRepository.findPatientUniqueDeleteStates()
|
||||
requestService.findPatientUniqueDeleteStates()
|
||||
} else {
|
||||
requestRepository.findPatientUniqueStates()
|
||||
requestService.findPatientUniqueStates()
|
||||
}
|
||||
|
||||
return states.map {
|
||||
@ -152,6 +154,10 @@ class StatisticsRestController(
|
||||
.build(),
|
||||
ServerSentEvent.builder<Any>()
|
||||
.event("deleterequestpatientstates").id("none").data(this.requestPatientStates(true))
|
||||
.build(),
|
||||
|
||||
ServerSentEvent.builder<Any>()
|
||||
.event("newrequest").id("none").data("newrequest")
|
||||
.build()
|
||||
)
|
||||
)
|
||||
|
@ -3,14 +3,34 @@ spring:
|
||||
compose:
|
||||
file: ./dev-compose.yml
|
||||
|
||||
security:
|
||||
oauth2:
|
||||
client:
|
||||
registration:
|
||||
custom:
|
||||
client-name: App-Dev
|
||||
client-id: app-dev
|
||||
client-secret: very-secret-ae3f7a-5a9f-1190
|
||||
scope:
|
||||
- openid
|
||||
provider:
|
||||
custom:
|
||||
issuer-uri: https://dnpm.dev/auth/realms/intern
|
||||
user-name-attribute: name
|
||||
|
||||
app:
|
||||
rest:
|
||||
uri: http://localhost:9000/bwhc/etl/api
|
||||
kafka:
|
||||
topic: test
|
||||
response-topic: test-response
|
||||
servers: kafka:9092
|
||||
#kafka:
|
||||
# topic: test
|
||||
# response-topic: test_response
|
||||
# servers: localhost:9094
|
||||
security:
|
||||
admin-user: admin
|
||||
admin-password: "{noop}very-secret"
|
||||
enable-oidc: "true"
|
||||
|
||||
server:
|
||||
port: 8000
|
||||
|
||||
|
||||
|
@ -4,4 +4,18 @@ spring:
|
||||
consumer:
|
||||
group-id: ${app.kafka.group-id}
|
||||
flyway:
|
||||
locations: "classpath:db/migration/{vendor}"
|
||||
locations: "classpath:db/migration/{vendor}"
|
||||
|
||||
web:
|
||||
resources:
|
||||
cache:
|
||||
cachecontrol:
|
||||
max-age: 1d
|
||||
chain:
|
||||
strategy:
|
||||
content:
|
||||
enabled: true
|
||||
paths: /**/*.js,/**/*.css,/**/*.svg,/**/*.jpeg
|
||||
|
||||
server:
|
||||
forward-headers-strategy: framework
|
@ -0,0 +1,8 @@
|
||||
CREATE TABLE IF NOT EXISTS token
|
||||
(
|
||||
id int auto_increment primary key,
|
||||
name varchar(255) not null,
|
||||
username varchar(255) not null unique,
|
||||
password varchar(255) not null,
|
||||
created_at datetime default utc_timestamp() not null
|
||||
);
|
@ -0,0 +1,7 @@
|
||||
CREATE TABLE IF NOT EXISTS user_role
|
||||
(
|
||||
id int auto_increment primary key,
|
||||
username varchar(255) not null unique,
|
||||
role varchar(255) not null,
|
||||
created_at datetime default utc_timestamp() not null
|
||||
);
|
@ -0,0 +1 @@
|
||||
ALTER TABLE request RENAME COLUMN patient_id TO patient_pseudonym;
|
@ -0,0 +1,9 @@
|
||||
CREATE TABLE IF NOT EXISTS token
|
||||
(
|
||||
id serial,
|
||||
name varchar(255) not null,
|
||||
username varchar(255) not null unique,
|
||||
password varchar(255) not null,
|
||||
created_at timestamp with time zone default now() not null,
|
||||
PRIMARY KEY (id)
|
||||
);
|
@ -0,0 +1,8 @@
|
||||
CREATE TABLE IF NOT EXISTS user_role
|
||||
(
|
||||
id serial,
|
||||
username varchar(255) not null unique,
|
||||
role varchar(255) not null,
|
||||
created_at timestamp with time zone default now() not null,
|
||||
PRIMARY KEY (id)
|
||||
);
|
@ -0,0 +1 @@
|
||||
ALTER TABLE request RENAME COLUMN patient_id TO patient_pseudonym;
|
BIN
src/main/resources/static/bg.jpeg
Normal file
After Width: | Height: | Size: 3.0 KiB |
45
src/main/resources/static/echarts.min.js
vendored
41
src/main/resources/static/icon.svg
Normal file
@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="256"
|
||||
height="256"
|
||||
viewBox="0 0 67.733332 67.733335"
|
||||
version="1.1"
|
||||
id="svg5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs2" />
|
||||
<g
|
||||
id="layer1">
|
||||
<g
|
||||
id="g26002"
|
||||
transform="matrix(1.5,0,0,1.5,-16.933333,-1.8487648)">
|
||||
<path
|
||||
id="path12437"
|
||||
transform="matrix(0.21771408,0,0,0.21771408,73.025692,24.874779)"
|
||||
style="fill:#f59e00;fill-opacity:1"
|
||||
d="m -110.41995,43.223174 -55.55561,-2e-6 -27.7778,-48.1125685 27.77781,-48.1125655 55.5556,3e-6 27.777803,48.1125679 z" />
|
||||
<path
|
||||
id="path13446"
|
||||
transform="matrix(0.21771408,0,0,0.21771408,54.882836,14.399994)"
|
||||
style="fill:#004d6e;fill-opacity:1"
|
||||
d="m -110.41995,43.223174 -55.55561,-2e-6 -27.7778,-48.1125685 27.77781,-48.1125655 55.5556,3e-6 27.777803,48.1125679 z" />
|
||||
<path
|
||||
id="path13448"
|
||||
transform="matrix(0.21771408,0,0,0.21771408,54.882835,35.349561)"
|
||||
style="fill:#706f6f;fill-opacity:1"
|
||||
d="m -110.41995,43.223174 -55.55561,-2e-6 -27.7778,-48.1125685 27.77781,-48.1125655 55.5556,3e-6 27.777803,48.1125679 z" />
|
||||
<path
|
||||
id="path25844"
|
||||
transform="matrix(0.21771408,0,0,0.21771408,60.930454,24.874778)"
|
||||
style="fill:#ffffff;fill-opacity:1"
|
||||
d="m -110.41995,43.223174 -55.55561,-2e-6 -27.7778,-48.1125685 27.77781,-48.1125655 55.5556,3e-6 27.777803,48.1125679 z" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 1.6 KiB |
BIN
src/main/resources/static/kafka.png
Normal file
After Width: | Height: | Size: 1.6 KiB |
@ -4,7 +4,7 @@ const dateFormat = new Intl.DateTimeFormat('de-DE', dateFormatOptions);
|
||||
const dateTimeFormatOptions = { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: 'numeric', second: 'numeric' };
|
||||
const dateTimeFormat = new Intl.DateTimeFormat('de-DE', dateTimeFormatOptions);
|
||||
|
||||
window.onload = () => {
|
||||
const formatTimeElements = () => {
|
||||
Array.from(document.getElementsByTagName('time')).forEach((timeTag) => {
|
||||
let date = Date.parse(timeTag.getAttribute('datetime'));
|
||||
if (! isNaN(date)) {
|
||||
@ -13,6 +13,9 @@ window.onload = () => {
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('load', formatTimeElements);
|
||||
window.addEventListener('htmx:afterRequest', formatTimeElements);
|
||||
|
||||
function drawPieChart(url, elemId, title, data) {
|
||||
if (data) {
|
||||
update(elemId, data);
|
||||
|
BIN
src/main/resources/static/server.png
Normal file
After Width: | Height: | Size: 1.4 KiB |
@ -1,44 +1,156 @@
|
||||
body {
|
||||
margin: 0;
|
||||
:root {
|
||||
--text: #333;
|
||||
--table-border: rgba(16, 24, 40, .1);
|
||||
|
||||
--dark: brightness(.90);
|
||||
|
||||
--bg-blue: rgb(0, 74, 157);
|
||||
--bg-blue-op: rgba(0, 74, 157, .35);
|
||||
|
||||
--bg-green: rgb(0, 128, 0);
|
||||
--bg-green-op: rgba(0, 128, 0, .35);
|
||||
|
||||
|
||||
--bg-yellow: rgb(255, 140, 0);
|
||||
--bg-yellow-op: rgba(255, 140, 0, .35);
|
||||
|
||||
|
||||
--bg-red: rgb(255, 0, 0);
|
||||
--bg-red-op: rgba(255, 0, 0, .35);
|
||||
|
||||
--bg-gray: rgb(112, 128, 144);
|
||||
--bg-gray-op: rgba(112, 128, 144, .35);
|
||||
}
|
||||
|
||||
* {
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
html {
|
||||
background: linear-gradient(-5deg, var(--bg-blue-op), transparent 10em);
|
||||
min-height: 100vh;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0 0 5em 0;
|
||||
font-size: .8rem;
|
||||
color: #333;
|
||||
color: var(--text);
|
||||
|
||||
min-height: 100vh;
|
||||
|
||||
background: url(bg.jpeg) no-repeat;
|
||||
background-size: contain;
|
||||
}
|
||||
|
||||
nav {
|
||||
margin: 0 auto;
|
||||
background: #d5dad5;
|
||||
height: 3rem;
|
||||
padding: 1em 0;
|
||||
|
||||
line-height: 1.5rem;
|
||||
max-width: 1140px;
|
||||
|
||||
border-bottom: 1px solid var(--table-border);
|
||||
}
|
||||
|
||||
nav a {
|
||||
color: #004a8f;
|
||||
text-transform: uppercase;
|
||||
nav a.nav-home {
|
||||
float: left;
|
||||
|
||||
color: var(--text);
|
||||
line-height: 1.5em;
|
||||
text-decoration: none;
|
||||
line-height: 2rem;
|
||||
font-weight: 700;
|
||||
|
||||
font-size: 2em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
nav a:hover {
|
||||
text-decoration: underline;
|
||||
nav a.nav-home > img {
|
||||
width: 1.5em;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
nav > ul {
|
||||
margin: 0 3rem;
|
||||
margin: 0 0 0 auto;
|
||||
padding: 0;
|
||||
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
nav > ul > li {
|
||||
background: #fbfbfb;
|
||||
display: block;
|
||||
float: left;
|
||||
padding: 2px 1rem;
|
||||
border-left: 1px solid #d5dad5;
|
||||
display: inline-block;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
nav > ul > li:first-of-type {
|
||||
border-left: none;
|
||||
nav > ul > li.login {
|
||||
margin: 0 0 0 1em;
|
||||
padding: 0 0 0 2em;
|
||||
border-left: 1px solid var(--table-border);
|
||||
line-height: 3.5em;
|
||||
}
|
||||
|
||||
nav > ul > li.login a {
|
||||
text-decoration: none;
|
||||
text-transform: none;
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
nav .login .user-name {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
nav > ul > li.login > span {
|
||||
display: inline-block;
|
||||
margin: 0 .5em;
|
||||
}
|
||||
|
||||
nav > ul > li.login .user-icon {
|
||||
flex-direction: column;
|
||||
display: inline flex;
|
||||
vertical-align: middle;
|
||||
inline-size: 4em;
|
||||
}
|
||||
|
||||
nav > ul > li.login .user-icon img {
|
||||
margin: 0 0 -1em 0;
|
||||
width: 80%;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
nav > ul > li.login .user-icon span {
|
||||
padding: 0 .6em;
|
||||
color: white;
|
||||
font-size: .8em;
|
||||
font-weight: bold;
|
||||
border-radius: 4px;
|
||||
line-height: normal;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
nav > ul > li.login .user-icon span.guest {
|
||||
background: darkslategray;
|
||||
}
|
||||
|
||||
nav > ul > li.login .user-icon span.user {
|
||||
background: darkgreen;
|
||||
}
|
||||
|
||||
nav > ul > li.login .user-icon span.admin {
|
||||
background: darkred;
|
||||
}
|
||||
|
||||
nav li a {
|
||||
color: var(--bg-blue);
|
||||
text-transform: uppercase;
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
nav li a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--bg-blue);
|
||||
}
|
||||
|
||||
.breadcrumps {
|
||||
@ -57,22 +169,30 @@ nav > ul > li:first-of-type {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.breadcrumps ul li+li:before {
|
||||
.breadcrumps ul li + li:before {
|
||||
padding: .4rem;
|
||||
color: gray;
|
||||
content: "/\00a0";
|
||||
}
|
||||
|
||||
.breadcrumps ul li a {
|
||||
color: #333333;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.centered {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
main {
|
||||
margin: 0 auto;
|
||||
max-width: 1140px;
|
||||
}
|
||||
|
||||
section {
|
||||
margin: 3em 0;
|
||||
}
|
||||
|
||||
form {
|
||||
margin: 1rem 0;
|
||||
padding: 1rem;
|
||||
@ -114,16 +234,139 @@ form.samplecode-input input:focus-visible {
|
||||
background: lightgreen;
|
||||
}
|
||||
|
||||
table {
|
||||
border-top: 1px solid lightgray;
|
||||
border-left: 1px solid lightgray;
|
||||
border-spacing: 0;
|
||||
border-radius: 3px;
|
||||
.login-form {
|
||||
width: fit-content;
|
||||
margin: 3em auto;
|
||||
padding: 2em 5em;
|
||||
|
||||
border: 1px solid var(--table-border);
|
||||
border-radius: .5em;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.login-form form {
|
||||
width: 20em;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-gap: .5em;
|
||||
|
||||
border: none;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.login-form img {
|
||||
margin: 0 auto;
|
||||
width: 4em;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.userrole-form {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.userrole-form form {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: none;
|
||||
|
||||
text-align: inherit;
|
||||
}
|
||||
|
||||
.login-form form *,
|
||||
.token-form form * {
|
||||
padding: 0.5em;
|
||||
border: 1px solid var(--table-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.login-form form hr,
|
||||
.token-form form hr,
|
||||
.userrole-form form hr {
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-form button,
|
||||
.login-form a.btn,
|
||||
.token-form button {
|
||||
margin: 1em 0;
|
||||
background: var(--bg-blue);
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.userrole-form form select {
|
||||
padding: 0.5em;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
line-height: 1.2rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.border {
|
||||
padding: 1.5em;
|
||||
border: 1px solid var(--table-border);
|
||||
border-radius: .5em;
|
||||
background: white;
|
||||
}
|
||||
|
||||
table, .chart {
|
||||
border: 1px solid var(--table-border);
|
||||
padding: 1.5em;
|
||||
|
||||
border-spacing: 0;
|
||||
border-radius: .5em;
|
||||
|
||||
background: white;
|
||||
}
|
||||
|
||||
table {
|
||||
min-width: 100%;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
table.config-table td:first-child {
|
||||
width: 24em;
|
||||
min-width: fit-content;
|
||||
}
|
||||
|
||||
table.config-table td > button:last-of-type {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.border > table {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.page-control {
|
||||
border-radius: .5em;
|
||||
padding: 1em 2em;
|
||||
text-align: center;
|
||||
|
||||
line-height: 1.75em;
|
||||
}
|
||||
|
||||
.page-control a {
|
||||
padding: 0 .25em;
|
||||
font-size: 1.75em;
|
||||
color: var(--bg-gray);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.page-control a[href] {
|
||||
color: var(--bg-blue);
|
||||
}
|
||||
|
||||
.page-control span {
|
||||
padding: 0 .5em;
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
|
||||
#samples-table.max {
|
||||
width: 100vw;
|
||||
position: fixed;
|
||||
@ -140,43 +383,97 @@ table.samples {
|
||||
display: block;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #eee;
|
||||
}
|
||||
th, td {
|
||||
padding: 0.4rem .2rem;
|
||||
|
||||
td, th {
|
||||
padding: .2rem;
|
||||
|
||||
border-right: 1px solid lightgray;
|
||||
border-bottom: 1px solid lightgray;
|
||||
line-height: 2em;
|
||||
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
th {
|
||||
border-bottom: 1px solid var(--bg-gray);
|
||||
}
|
||||
|
||||
td {
|
||||
font-family: monospace;
|
||||
border-bottom: 1px solid var(--bg-gray-op);
|
||||
}
|
||||
|
||||
td.bg-green, th.bg-green {
|
||||
background: green;
|
||||
color: white;
|
||||
tr:last-of-type > td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
td.bg-yellow, th.bg-yellow {
|
||||
background: darkorange;
|
||||
color: white;
|
||||
td > small {
|
||||
display: block;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
td.bg-red, th.bg-red {
|
||||
background: red;
|
||||
color: white;
|
||||
td.patient-id {
|
||||
width: 32em;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
display: block;
|
||||
}
|
||||
|
||||
td.bg-gray, th.bg-gray {
|
||||
background: slategray;
|
||||
td.bg-blue, th.bg-blue,
|
||||
td.bg-green, th.bg-green,
|
||||
td.bg-yellow, th.bg-yellow,
|
||||
td.bg-red, th.bg-red,
|
||||
td.bg-gray, th.bg-gray
|
||||
{
|
||||
width: 8em;
|
||||
}
|
||||
|
||||
td.bg-blue > small, th.bg-blue > small {
|
||||
background: var(--bg-blue);
|
||||
color: white;
|
||||
border-radius: 0.4em;
|
||||
}
|
||||
|
||||
td.bg-green > small, th.bg-green > small {
|
||||
background: var(--bg-green);
|
||||
color: white;
|
||||
border-radius: 0.4em;
|
||||
}
|
||||
|
||||
td.bg-yellow > small, th.bg-yellow > small {
|
||||
background: var(--bg-yellow);
|
||||
color: white;
|
||||
border-radius: 0.4em;
|
||||
}
|
||||
|
||||
td.bg-red > small, th.bg-red > small {
|
||||
background: var(--bg-red);
|
||||
color: white;
|
||||
border-radius: 0.4em;
|
||||
}
|
||||
|
||||
td.bg-gray > small, th.bg-gray > small {
|
||||
background: var(--bg-gray);
|
||||
color: white;
|
||||
border-radius: 0.4em;
|
||||
}
|
||||
|
||||
.bg-path {
|
||||
background: var(--bg-gray-op);
|
||||
}
|
||||
|
||||
.bg-from {
|
||||
background: var(--bg-red-op);
|
||||
}
|
||||
|
||||
.bg-to {
|
||||
background: var(--bg-green-op);
|
||||
}
|
||||
|
||||
.bg-path, .bg-from, .bg-to {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 3px;
|
||||
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
td.bg-shaded, th.bg-shaded {
|
||||
@ -196,7 +493,6 @@ td.clipboard.clipped {
|
||||
padding: 4px 8px;
|
||||
|
||||
line-height: 1.2rem;
|
||||
vertical-align: middle;
|
||||
|
||||
border: 0 solid transparent;
|
||||
border-radius: 3px;
|
||||
@ -208,38 +504,38 @@ td.clipboard.clipped {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn:active,
|
||||
.btn:hover {
|
||||
filter: drop-shadow(1px 2px 2px gray);
|
||||
filter: drop-shadow(0px 1px 1px var(--bg-gray)) var(--dark);
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
filter: drop-shadow(1px 1px 2px gray);
|
||||
translate: 0 1px;
|
||||
}
|
||||
|
||||
.btn.btn-red {
|
||||
background: red;
|
||||
background: var(--bg-red);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn.btn-red:hover, .btn.btn-red:active {
|
||||
background: darkred !important;
|
||||
}
|
||||
|
||||
.btn.btn-blue {
|
||||
background: slategray;
|
||||
background: var(--bg-blue);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn.btn-blue:hover, .btn.btn-blue:active {
|
||||
background: darkslategray !important;
|
||||
}
|
||||
|
||||
.btn.btn-delete:before {
|
||||
content: '\1F5D1';
|
||||
padding: .2rem;
|
||||
}
|
||||
|
||||
button:disabled,
|
||||
.btn:disabled {
|
||||
background: slategray !important;
|
||||
color: lightgray;
|
||||
filter: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
input.inline {
|
||||
border: none;
|
||||
font-size: 1.1rem;
|
||||
@ -275,19 +571,140 @@ input.inline:focus-visible {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.chart {
|
||||
padding: 1rem;
|
||||
margin: .2rem;
|
||||
.charts {
|
||||
display: grid;
|
||||
grid-gap: 1em;
|
||||
grid-template:
|
||||
"a b" 28em
|
||||
"c c" 28em / 1fr 1fr;
|
||||
}
|
||||
|
||||
border: 1px solid lightgray;
|
||||
.charts > .grid-left {
|
||||
grid-area: a;
|
||||
}
|
||||
|
||||
.charts > .grid-right {
|
||||
grid-area: b;
|
||||
}
|
||||
|
||||
.charts > .grid-full {
|
||||
grid-area: c;
|
||||
}
|
||||
|
||||
.connection-display {
|
||||
display: grid;
|
||||
grid-template-columns: 10em 16em 10em;
|
||||
place-items: center;
|
||||
width: fit-content;
|
||||
margin: 1em auto;
|
||||
}
|
||||
|
||||
.connection-display > * {
|
||||
text-align: center;
|
||||
margin: auto 0;
|
||||
}
|
||||
|
||||
.connection-display .connection {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: repeating-linear-gradient(to left, white, white 2px, transparent 2px, transparent 8px, white 8px) var(--bg-red);
|
||||
}
|
||||
|
||||
.connection-display .connection.available {
|
||||
background: var(--bg-green);
|
||||
}
|
||||
|
||||
.notification {
|
||||
margin: 1em;
|
||||
padding: .5em;
|
||||
border-radius: 3px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
width: calc(100% - 2.4rem - 4px);
|
||||
height: 320px;
|
||||
.notification.info {
|
||||
color: var(--bg-blue);
|
||||
}
|
||||
|
||||
.notification.success {
|
||||
color: var(--bg-green);
|
||||
}
|
||||
|
||||
.notification.notice {
|
||||
color: var(--bg-yellow);
|
||||
}
|
||||
|
||||
.notification.error {
|
||||
color: var(--bg-red);
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 1em;
|
||||
border: none;
|
||||
border-radius: 3px 3px 0 0;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.tab:hover,
|
||||
.tab.active {
|
||||
background: var(--bg-gray);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.tabcontent {
|
||||
border: 2px solid var(--bg-gray);
|
||||
border-radius: 0 .5em .5em .5em;
|
||||
display: none;
|
||||
padding: 1em;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.tabcontent.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
a.reload {
|
||||
display: none;
|
||||
position: absolute;
|
||||
height: 1.2em;
|
||||
width: 1.2em;
|
||||
background: var(--bg-red);
|
||||
border-radius: 50%;
|
||||
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
font-size: .6em;
|
||||
align-content: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.new-token {
|
||||
padding: 1em;
|
||||
background: var(--bg-green-op);
|
||||
}
|
||||
|
||||
.new-token > pre {
|
||||
margin: 0;
|
||||
border: 1px solid var(--bg-green);
|
||||
padding: .5em;
|
||||
width: max-content;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.chart-50pc {
|
||||
width: calc(50% - 2.4rem - 4px);
|
||||
.no-token {
|
||||
padding: 1em;
|
||||
background: var(--bg-red-op);
|
||||
}
|
||||
|
||||
.issue-message {
|
||||
font-family: monospace;
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
.issue-path {
|
||||
font-family: monospace;
|
||||
line-height: 1rem;
|
||||
}
|
11
src/main/resources/static/user.svg
Normal file
@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
<svg width="24" height="24" version="1.1" viewBox="0 0 6.35 6.35" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="matrix(1.2 0 0 1.2 -108.01 -85.977)">
|
||||
<rect x="90.01" y="71.647" width="5.2917" height="5.2917" rx=".96212" fill="#b3b3b3"/>
|
||||
<g transform="matrix(1.6667 0 0 1.6667 -60.888 -47.952)" fill="#fff">
|
||||
<circle cx="92.126" cy="72.802" r=".70556"/>
|
||||
<path d="m91.068 74.598a1.0583 1.0583 0 0 1 1.0583-1.0583 1.0583 1.0583 0 0 1 1.0583 1.0583h-1.0583z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 588 B |
130
src/main/resources/templates/configs.html
Normal file
@ -0,0 +1,130 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de" xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>ETL-Prozessor</title>
|
||||
<link rel="stylesheet" th:href="@{/style.css}" />
|
||||
</head>
|
||||
<body>
|
||||
<div th:replace="~{fragments.html :: nav}"></div>
|
||||
<main>
|
||||
<h1>Konfiguration</h1>
|
||||
|
||||
<div class="tabs">
|
||||
<button class="tab active" onclick="selectTab(this, 'common');">Allgemeine Informationen</button>
|
||||
<button class="tab" onclick="selectTab(this, 'security');">Sicherheit</button>
|
||||
<button class="tab" onclick="selectTab(this, 'transformation');">Transformationen</button>
|
||||
</div>
|
||||
|
||||
<div id="common" class="tabcontent active">
|
||||
<section>
|
||||
<h2>🔧 Allgemeine Konfiguration</h2>
|
||||
<table class="config-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Wert</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Pseudonym erzeugt über</td>
|
||||
<td>[[ ${pseudonymGenerator} ]]</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>MTBFile-Sender</td>
|
||||
<td>[[ ${mtbFileSender} ]]</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td th:if="${mtbFileSender.startsWith('Rest')}">REST-Endpunkt</td>
|
||||
<td th:if="${mtbFileSender.startsWith('Kafka')}">Kafka-Broker und Topics</td>
|
||||
<td>[[ ${mtbFileEndpoint} ]]</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section hx-ext="sse" th:sse-connect="@{/configs/events}">
|
||||
<div th:insert="~{configs/gPasConnectionAvailable.html}" th:hx-get="@{/configs?gPasConnectionAvailable}" hx-trigger="sse:gpas-connection-check">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section hx-ext="sse" th:sse-connect="@{/configs/events}">
|
||||
<div th:insert="~{configs/gIcsConnectionAvailable.html}" th:hx-get="@{/configs?gIcsConnectionAvailable}" hx-trigger="sse:gics-connection-check">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section hx-ext="sse" th:sse-connect="@{/configs/events}">
|
||||
<div th:insert="~{configs/outputConnectionAvailable.html}" th:hx-get="@{/configs?outputConnectionAvailable}" hx-trigger="sse:output-connection-check">
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="security" class="tabcontent">
|
||||
<section th:insert="~{configs/tokens.html}">
|
||||
</section>
|
||||
|
||||
<section th:insert="~{configs/userroles.html}">
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="transformation" class="tabcontent">
|
||||
<section>
|
||||
<h2><span th:if="${not transformations.isEmpty()}">✅</span><span th:if="${transformations.isEmpty()}">⛔</span> Transformationen</h2>
|
||||
|
||||
<h3>Syntax</h3>
|
||||
Hier einige Beispiele zum Syntax des JSON-Path
|
||||
<ul>
|
||||
<li style="padding: 0.6rem 0;"><span class="bg-path">diagnoses[*].icdO3T.version</span>: Ersetze die ICD-O3T-Version in allen Diagnosen, z.B. zur Version der deutschen Übersetzung</li>
|
||||
<li style="padding: 0.6rem 0;"><span class="bg-path">patient.gender</span>: Ersetze das Geschlecht des Patienten, z.B. in das von bwHC verlangte Format</li>
|
||||
</ul>
|
||||
|
||||
<h3>Konfigurierte Transformationen</h3>
|
||||
<th:block th:if="${transformations.isEmpty()}">
|
||||
<p>
|
||||
Keine konfigurierten Transformationen.
|
||||
</p>
|
||||
</th:block>
|
||||
<th:block th:if="${not transformations.isEmpty()}">
|
||||
<p>
|
||||
Hier sehen Sie eine Übersicht der konfigurierten Transformationen.
|
||||
</p>
|
||||
|
||||
<table class="config-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>JSON-Path</th>
|
||||
<th>Transformation von ⇒ nach</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr th:each="transformation : ${transformations}">
|
||||
<td>
|
||||
<span class="bg-path" title="Ersetze Wert(e) an dieser Stelle im MTB-File">[[ ${transformation.path} ]]</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="bg-from" title="Ersetze immer dann, wenn dieser Wert enthalten ist">[[ ${transformation.existingValue} ]]</span>
|
||||
<strong>⇒</strong>
|
||||
<span class="bg-to" title="Ersetze durch diesen Wert">[[ ${transformation.newValue} ]]</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</th:block>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
<script th:src="@{/scripts.js}"></script>
|
||||
<script th:src="@{/webjars/htmx.org/dist/htmx.min.js}"></script>
|
||||
<script th:src="@{/webjars/htmx.org/dist/ext/sse.js}"></script>
|
||||
<script>
|
||||
function selectTab(self, elem) {
|
||||
Array.from(document.getElementsByClassName('tab')).forEach(e => e.className = 'tab');
|
||||
self.className = 'tab active';
|
||||
|
||||
Array.from(document.getElementsByClassName('tabcontent')).forEach(e => e.className = 'tabcontent');
|
||||
document.getElementById(elem).className = 'tabcontent active';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
@ -0,0 +1,24 @@
|
||||
<th:block th:if="${gIcsConnectionAvailable == null}">
|
||||
<h2><span>🟦</span> gICS nicht konfiguriert - Einwilligung wird über Dateiinhalt geprüft</h2>
|
||||
</th:block>
|
||||
<th:block th:if="${gIcsConnectionAvailable != null}">
|
||||
<h2><span th:if="${gIcsConnectionAvailable.available}">✅</span><span th:if="${not(gIcsConnectionAvailable.available)}">⚡</span> Verbindung zu gICS</h2>
|
||||
<div>
|
||||
Stand: <time style="font-weight: bold" th:datetime="${#temporals.formatISO(gIcsConnectionAvailable.timestamp)}" th:text="${#temporals.formatISO(gIcsConnectionAvailable.timestamp)}"></time>
|
||||
|
|
||||
Letzte Änderung: <time style="font-weight: bold" th:datetime="${#temporals.formatISO(gIcsConnectionAvailable.lastChange)}" th:text="${#temporals.formatISO(gIcsConnectionAvailable.lastChange)}"></time>
|
||||
</div>
|
||||
<div>
|
||||
<span>Die Verbindung ist aktuell</span>
|
||||
<strong th:if="${gIcsConnectionAvailable.available}" style="color: green">verfügbar.</strong>
|
||||
<strong th:if="${not(gIcsConnectionAvailable.available)}" style="color: red">nicht verfügbar.</strong>
|
||||
</div>
|
||||
<div class="connection-display border">
|
||||
<img th:src="@{/server.png}" alt="ETL-Processor" />
|
||||
<span class="connection" th:classappend="${gIcsConnectionAvailable.available ? 'available' : ''}"></span>
|
||||
<img th:src="@{/server.png}" alt="gICS" />
|
||||
<span>ETL-Processor</span>
|
||||
<span></span>
|
||||
<span>gICS</span>
|
||||
</div>
|
||||
</th:block>
|
@ -0,0 +1,24 @@
|
||||
<th:block th:if="${gPasConnectionAvailable == null}">
|
||||
<h2><span>🟦</span> gPAS nicht konfiguriert - Patienten-IDs werden intern anonymisiert</h2>
|
||||
</th:block>
|
||||
<th:block th:if="${gPasConnectionAvailable != null}">
|
||||
<h2><span th:if="${gPasConnectionAvailable.available}">✅</span><span th:if="${not(gPasConnectionAvailable.available)}">⚡</span> Verbindung zu gPAS</h2>
|
||||
<div>
|
||||
Stand: <time style="font-weight: bold" th:datetime="${#temporals.formatISO(gPasConnectionAvailable.timestamp)}" th:text="${#temporals.formatISO(gPasConnectionAvailable.timestamp)}"></time>
|
||||
|
|
||||
Letzte Änderung: <time style="font-weight: bold" th:datetime="${#temporals.formatISO(gPasConnectionAvailable.lastChange)}" th:text="${#temporals.formatISO(gPasConnectionAvailable.lastChange)}"></time>
|
||||
</div>
|
||||
<div>
|
||||
<span>Die Verbindung ist aktuell</span>
|
||||
<strong th:if="${gPasConnectionAvailable.available}" style="color: green">verfügbar.</strong>
|
||||
<strong th:if="${not(gPasConnectionAvailable.available)}" style="color: red">nicht verfügbar.</strong>
|
||||
</div>
|
||||
<div class="connection-display border">
|
||||
<img th:src="@{/server.png}" alt="ETL-Processor" />
|
||||
<span class="connection" th:classappend="${gPasConnectionAvailable.available ? 'available' : ''}"></span>
|
||||
<img th:src="@{/server.png}" alt="gPAS" />
|
||||
<span>ETL-Processor</span>
|
||||
<span></span>
|
||||
<span>gPAS</span>
|
||||
</div>
|
||||
</th:block>
|
@ -0,0 +1,27 @@
|
||||
<th:block th:if="${outputConnectionAvailable == null}">
|
||||
<h2><span>🟦</span> Keine Ausgabenkonfiguration</h2>
|
||||
</th:block>
|
||||
<th:block th:if="${outputConnectionAvailable != null}">
|
||||
<h2><span th:if="${outputConnectionAvailable.available}">✅</span><span th:if="${not(outputConnectionAvailable.available)}">⚡</span> MTB-File Verbindung</h2>
|
||||
<div>
|
||||
Stand: <time style="font-weight: bold" th:datetime="${#temporals.formatISO(outputConnectionAvailable.timestamp)}" th:text="${#temporals.formatISO(outputConnectionAvailable.timestamp)}"></time>
|
||||
|
|
||||
Letzte Änderung: <time style="font-weight: bold" th:datetime="${#temporals.formatISO(outputConnectionAvailable.lastChange)}" th:text="${#temporals.formatISO(outputConnectionAvailable.lastChange)}"></time>
|
||||
</div>
|
||||
<div>
|
||||
Verbindung über <code>[[ ${mtbFileSender} ]]</code>. Die Verbindung ist aktuell
|
||||
<strong th:if="${outputConnectionAvailable.available}" style="color: green">verfügbar.</strong>
|
||||
<strong th:if="${not(outputConnectionAvailable.available)}" style="color: red">nicht verfügbar.</strong>
|
||||
</div>
|
||||
<div class="connection-display border">
|
||||
<img th:src="@{/server.png}" alt="ETL-Processor" />
|
||||
<span class="connection" th:classappend="${outputConnectionAvailable.available ? 'available' : ''}"></span>
|
||||
<img th:if="${mtbFileSender.startsWith('Rest')}" th:src="@{/server.png}" alt="bwHC-Backend" />
|
||||
<img th:if="${mtbFileSender.startsWith('Kafka')}" th:src="@{/kafka.png}" alt="Kafka-Broker" />
|
||||
<span>ETL-Processor</span>
|
||||
<span></span>
|
||||
<span th:if="${mtbFileSender.startsWith('RestBwhc')}">bwHC-Backend</span>
|
||||
<span th:if="${mtbFileSender.startsWith('RestDip')}">DNPM:DIP-Backend</span>
|
||||
<span th:if="${mtbFileSender.startsWith('Kafka')}">Kafka-Broker</span>
|
||||
</div>
|
||||
</th:block>
|
40
src/main/resources/templates/configs/tokens.html
Normal file
@ -0,0 +1,40 @@
|
||||
<div th:if="${not tokensEnabled}">
|
||||
<h2><span>⛔</span> Tokens</h2>
|
||||
<p>Die Verwendung von Tokens ist nicht aktiviert.</p>
|
||||
</div>
|
||||
|
||||
<div id="tokens" th:if="${tokensEnabled}">
|
||||
<h2><span>✅</span> Tokens</h2>
|
||||
<div class="border">
|
||||
<div th:if="${tokens.isEmpty()}">Noch keine Tokens vorhanden.</div>
|
||||
<table th:if="${not tokens.isEmpty()}" class="config-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Erstellt</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr th:each="token : ${tokens}">
|
||||
<td>[[ ${token.name} ]]</td>
|
||||
<td>
|
||||
<time th:datetime="${token.createdAt}">[[ ${token.createdAt} ]]</time>
|
||||
<button class="btn btn-red" th:hx-delete="@{/configs/tokens/{id}(id=${token.id})}" hx-target="#tokens">Löschen</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div th:if="${newTokenValue != null and success}" class="new-token">
|
||||
Verwendung über HTTP-Basic. Bitte notieren, wird nicht erneut angezeigt: <pre>[[ ${newTokenValue} ]]</pre>
|
||||
</div>
|
||||
<div th:if="${success != null and not success}" class="no-token">
|
||||
Das Token konnte nicht erzeugt werden. Versuchen Sie einen anderen Namen.
|
||||
</div>
|
||||
<div class="token-form">
|
||||
<form th:hx-post="@{/configs/tokens}" hx-target="#tokens">
|
||||
<input placeholder="Token-Name" name="name" required />
|
||||
<button class="btn">Token Erstellen</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
37
src/main/resources/templates/configs/userroles.html
Normal file
@ -0,0 +1,37 @@
|
||||
<div th:if="${not userRolesEnabled}">
|
||||
<h2><span>⛔</span> Benutzerberechtigungen</h2>
|
||||
<p>Die Verwendung von rollenbasierten Benutzerberechtigungen ist nicht aktiviert.</p>
|
||||
</div>
|
||||
|
||||
<div id="userroles" th:if="${userRolesEnabled}">
|
||||
<h2><span>✅</span> Benutzerberechtigungen</h2>
|
||||
<div class="border">
|
||||
<div th:if="${userRoles.isEmpty()}">Noch keine Benutzerberechtigungen vorhanden.</div>
|
||||
<table th:if="${not userRoles.isEmpty()}" class="config-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Benutzername</th>
|
||||
<th>Rolle</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr th:each="userRole : ${userRoles}">
|
||||
<td>[[ ${userRole.username} ]]</td>
|
||||
<td>
|
||||
<div class="userrole-form">
|
||||
<form th:hx-put="@{/configs/userroles/{id}(id=${userRole.id})}" hx-target="#userroles">
|
||||
<select name="role" th:disabled="${#authorization.authentication.getName() == userRole.username}">
|
||||
<option th:selected="${userRole.role.value == 'guest'}" value="GUEST">Gast</option>
|
||||
<option th:selected="${userRole.role.value == 'user'}" value="USER">Benutzer</option>
|
||||
<option th:selected="${userRole.role.value == 'admin'}" value="ADMIN">Administrator</option>
|
||||
</select>
|
||||
<button class="btn btn-blue" th:disabled="${#authorization.authentication.getName() == userRole.username}">Übernehmen</button>
|
||||
</form>
|
||||
</div>
|
||||
<button class="btn btn-red" th:hx-delete="@{/configs/userroles/{id}(id=${userRole.id})}" hx-target="#userroles" th:disabled="${#authorization.authentication.getName() == userRole.username}">Löschen</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<html xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="stylesheet" th:href="@{/style.css}" />
|
||||
@ -7,9 +7,33 @@
|
||||
<body>
|
||||
<div th:fragment="nav">
|
||||
<nav>
|
||||
<span>
|
||||
<a class="nav-home" th:href="@{/}">
|
||||
<img th:src="@{/icon.svg}" alt="Icon" />
|
||||
<span>ETL-Processor</span>
|
||||
</a>
|
||||
</span>
|
||||
<ul>
|
||||
<li><a th:href="@{/}">Übersicht</a></li>
|
||||
<li><a th:href="@{/statistics}">Statistiken</a></li>
|
||||
<li sec:authorize="hasRole('ADMIN')">
|
||||
<a th:href="@{/configs}">Konfiguration</a>
|
||||
</li>
|
||||
<li class="login" sec:authorize="not isAuthenticated()">
|
||||
<a class="btn btn-blue" th:href="@{/login}">Login</a>
|
||||
</li>
|
||||
<li class="login" sec:authorize="isAuthenticated()">
|
||||
<span>
|
||||
<div class="user-icon">
|
||||
<img th:src="@{/user.svg}" alt="User-Image">
|
||||
<span sec:authorize="hasRole('ADMIN')" class="user-role admin">Admin</span>
|
||||
<span sec:authorize="hasRole('USER')" class="user-role user">User</span>
|
||||
<span sec:authorize="hasRole('GUEST')" class="user-role guest">Guest</span>
|
||||
</div>
|
||||
<span class="user-name" sec:authentication="name">?</span>
|
||||
</span>
|
||||
<a class="btn btn-red" th:href="@{/logout}">Abmelden</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de" xmlns:th="http://www.thymeleaf.org">
|
||||
<html lang="de" xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>ETL-Prozessor</title>
|
||||
@ -9,37 +9,95 @@
|
||||
<div th:replace="~{fragments.html :: nav}"></div>
|
||||
<main>
|
||||
|
||||
<h1>Letzte Anfragen</h1>
|
||||
<h1>Alle Anfragen<a id="reload-notify" class="reload" title="Neue Anfragen" th:href="@{/}">⟳</a></h1>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>Typ</th>
|
||||
<th>ID</th>
|
||||
<th>Datum</th>
|
||||
<th>Patienten-ID</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr th:each="request : ${requests}">
|
||||
<td th:if="${request.status.value.contains('success')}" class="bg-green"><small>[[ ${request.status} ]]</small></td>
|
||||
<td th:if="${request.status.value.contains('warning')}" class="bg-yellow"><small>[[ ${request.status} ]]</small></td>
|
||||
<td th:if="${request.status.value.contains('error')}" class="bg-red"><small>[[ ${request.status} ]]</small></td>
|
||||
<td th:if="${request.status.value == 'unknown'}" class="bg-gray"><small>[[ ${request.status} ]]</small></td>
|
||||
<td th:if="${request.status.value == 'duplication'}" class="bg-gray"><small>[[ ${request.status} ]]</small></td>
|
||||
<td th:style="${request.type.value == 'delete'} ? 'color: red;'"><small>[[ ${request.type} ]]</small></td>
|
||||
<td th:if="not ${request.report}">[[ ${request.uuid} ]]</td>
|
||||
<td th:if="${request.report}">
|
||||
<a th:href="@{/report/{id}(id=${request.uuid})}">[[ ${request.uuid} ]]</a>
|
||||
</td>
|
||||
<td><time th:datetime="${request.processedAt}">[[ ${request.processedAt} ]]</time></td>
|
||||
<td>[[ ${request.patientId} ]]</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div>
|
||||
<h2 th:if="${patientPseudonym != null}">
|
||||
Betreffend Patienten-Pseudonym <span class="monospace" th:text="${patientPseudonym}">***</span>
|
||||
<a class="btn btn-blue" th:if="${patientPseudonym != null}" th:href="@{/}">Alle anzeigen</a>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="border" th:if="${requests.totalElements == 0}">
|
||||
<div class="notification info">Noch keine Anfragen eingegangen</div>
|
||||
</div>
|
||||
|
||||
<div class="border" th:if="${requests.totalElements > 0}">
|
||||
<div th:if="${patientPseudonym == null}" class="page-control">
|
||||
<a id="first-page-link" th:href="@{/(page=${0})}" title="Zum Anfang: Taste W" th:if="${not requests.isFirst()}">⇤</a><a th:if="${requests.isFirst()}">⇤</a>
|
||||
<a id="prev-page-link" th:href="@{/(page=${requests.getNumber() - 1})}" title="Seite zurück: Taste A" th:if="${not requests.isFirst()}">←</a><a th:if="${requests.isFirst()}">←</a>
|
||||
<span>Seite [[ ${requests.getNumber() + 1} ]] von [[ ${requests.getTotalPages()} ]]</span>
|
||||
<a id="next-page-link" th:href="@{/(page=${requests.getNumber() + 1})}" title="Seite vor: Taste D" th:if="${not requests.isLast()}">→</a><a th:if="${requests.isLast()}">→</a>
|
||||
<a id="last-page-link" th:href="@{/(page=${requests.getTotalPages() - 1})}" title="Zum Ende: Taste S" th:if="${not requests.isLast()}">⇥</a><a th:if="${requests.isLast()}">⇥</a>
|
||||
</div>
|
||||
<div th:if="${patientPseudonym != null}" class="page-control">
|
||||
<a id="first-page-link" th:href="@{/patient/{patientPseudonym}(patientPseudonym=${patientPseudonym},page=${0})}" title="Zum Anfang: Taste W" th:if="${not requests.isFirst()}">⇤</a><a th:if="${requests.isFirst()}">⇤</a>
|
||||
<a id="prev-page-link" th:href="@{/patient/{patientPseudonym}(patientPseudonym=${patientPseudonym},page=${requests.getNumber() - 1})}" title="Seite zurück: Taste A" th:if="${not requests.isFirst()}">←</a><a th:if="${requests.isFirst()}">←</a>
|
||||
<span>Seite [[ ${requests.getNumber() + 1} ]] von [[ ${requests.getTotalPages()} ]]</span>
|
||||
<a id="next-page-link" th:href="@{/patient/{patientPseudonym}(patientPseudonym=${patientPseudonym},page=${requests.getNumber() + 1})}" title="Seite vor: Taste D" th:if="${not requests.isLast()}">→</a><a th:if="${requests.isLast()}">→</a>
|
||||
<a id="last-page-link" th:href="@{/patient/{patientPseudonym}(patientPseudonym=${patientPseudonym},page=${requests.getTotalPages() - 1})}" title="Zum Ende: Taste S" th:if="${not requests.isLast()}">⇥</a><a th:if="${requests.isLast()}">⇥</a>
|
||||
</div>
|
||||
<table class="paged">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>Typ</th>
|
||||
<th>ID</th>
|
||||
<th>Datum</th>
|
||||
<th>Patienten-ID</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr th:each="request : ${requests}">
|
||||
<td th:if="${request.status.value.contains('success')}" class="bg-green"><small>[[ ${request.status} ]]</small></td>
|
||||
<td th:if="${request.status.value.contains('warning')}" class="bg-yellow"><small>[[ ${request.status} ]]</small></td>
|
||||
<td th:if="${request.status.value.contains('error')}" class="bg-red"><small>[[ ${request.status} ]]</small></td>
|
||||
<td th:if="${request.status.value == 'unknown'}" class="bg-gray"><small>[[ ${request.status} ]]</small></td>
|
||||
<td th:if="${request.status.value == 'duplication'}" class="bg-gray"><small>[[ ${request.status} ]]</small></td>
|
||||
<td th:style="${request.type.value == 'delete'} ? 'color: red;'"><small>[[ ${request.type} ]]</small></td>
|
||||
<td th:if="not ${request.report}">[[ ${request.uuid} ]]</td>
|
||||
<td th:if="${request.report}">
|
||||
<a th:href="@{/report/{id}(id=${request.uuid})}" sec:authorize="hasRole('USER') or hasRole('ADMIN')">[[ ${request.uuid} ]]</a>
|
||||
<th:block sec:authorize="not (hasRole('USER') or hasRole('ADMIN'))">[[ ${request.uuid} ]]</th:block>
|
||||
</td>
|
||||
<td><time th:datetime="${request.processedAt}">[[ ${request.processedAt} ]]</time></td>
|
||||
<td class="patient-id" th:if="${patientPseudonym != null}" sec:authorize="hasRole('USER') or hasRole('ADMIN')">
|
||||
[[ ${request.patientPseudonym} ]]
|
||||
</td>
|
||||
<td class="patient-id" th:if="${patientPseudonym == null}" sec:authorize="hasRole('USER') or hasRole('ADMIN')">
|
||||
<a th:href="@{/patient/{pid}(pid=${request.patientPseudonym})}">[[ ${request.patientPseudonym} ]]</a>
|
||||
</td>
|
||||
<td class="patient-id" sec:authorize="not (hasRole('USER') or hasRole('ADMIN'))">***</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
<script th:src="@{/scripts.js}"></script>
|
||||
<script>
|
||||
window.addEventListener('load', () => {
|
||||
let keyBindings = {
|
||||
'w': 'first-page-link',
|
||||
'a': 'prev-page-link',
|
||||
'd': 'next-page-link',
|
||||
's': 'last-page-link'
|
||||
};
|
||||
window.onkeydown = (event) => {
|
||||
for (const [key, elemId] of Object.entries(keyBindings)) {
|
||||
if (event.key === key && document.getElementById(elemId)) {
|
||||
document.getElementById(elemId).style.background = 'yellow';
|
||||
document.getElementById(elemId).click();
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const eventSource = new EventSource('statistics/events');
|
||||
eventSource.addEventListener('newrequest', event => {
|
||||
console.log(event);
|
||||
document.getElementById('reload-notify').style.display = 'inline-flex';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|