mirror of
https://github.com/pcvolkmer/etl-processor.git
synced 2025-07-01 22:22:53 +00:00
Compare commits
28 Commits
Author | SHA1 | Date | |
---|---|---|---|
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 |
14
README.md
14
README.md
@ -49,6 +49,20 @@ Wurde die Verwendung von gPAS konfiguriert, so sind weitere Angaben zu konfiguri
|
||||
* `APP_PSEUDONYMIZE_GPAS_PASSWORD`: gPas Basic-Auth Passwort
|
||||
* `APP_PSEUDONYMIZE_GPAS_SSLCALOCATION`: Root Zertifikat für gPas, falls es dediziert hinzugefügt werden muss.
|
||||
|
||||
## 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 vom bwHC-Backend 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
|
||||
|
||||
Für REST-Requests als auch zur Nutzung von Kafka-Topics können Endpunkte konfiguriert werden.
|
||||
|
@ -4,14 +4,14 @@ import org.springframework.boot.gradle.tasks.bundling.BootBuildImage
|
||||
|
||||
plugins {
|
||||
war
|
||||
id("org.springframework.boot") version "3.1.3"
|
||||
id("io.spring.dependency-management") version "1.1.3"
|
||||
kotlin("jvm") version "1.9.10"
|
||||
kotlin("plugin.spring") version "1.9.10"
|
||||
id("org.springframework.boot") version "3.2.0"
|
||||
id("io.spring.dependency-management") version "1.1.4"
|
||||
kotlin("jvm") version "1.9.21"
|
||||
kotlin("plugin.spring") version "1.9.21"
|
||||
}
|
||||
|
||||
group = "de.ukw.ccc"
|
||||
version = "0.1.2"
|
||||
version = "0.3.0"
|
||||
|
||||
var versions = mapOf(
|
||||
"bwhc-dto-java" to "0.2.0",
|
||||
@ -21,8 +21,8 @@ var versions = mapOf(
|
||||
)
|
||||
|
||||
// Override Apache Kafka to be used
|
||||
// Fixes: CVE-2023-34455, CVE-2023-34454, CVE-2023-34453
|
||||
extra["kafka.version"] = "3.5.1"
|
||||
// Fixes: CVE-2023-34455, CVE-2023-34454, CVE-2023-34453 and CVE-2023-43642
|
||||
extra["kafka.version"] = "3.6.0"
|
||||
|
||||
java {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
@ -69,6 +69,7 @@ dependencies {
|
||||
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:${versions["httpclient5"]}")
|
||||
implementation("com.jayway.jsonpath:json-path")
|
||||
runtimeOnly("org.mariadb.jdbc:mariadb-java-client")
|
||||
runtimeOnly("org.postgresql:postgresql")
|
||||
developmentOnly("org.springframework.boot:spring-boot-devtools")
|
||||
|
@ -1,6 +1,4 @@
|
||||
services:
|
||||
# Note: Make sure, hostname "kafka" points to 127.0.0.1
|
||||
# otherwise connection will not be available
|
||||
kafka:
|
||||
image: bitnami/kafka
|
||||
hostname: kafka
|
||||
|
@ -19,15 +19,27 @@
|
||||
|
||||
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.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.junit.jupiter.SpringExtension
|
||||
import org.springframework.test.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.post
|
||||
import org.testcontainers.junit.jupiter.Testcontainers
|
||||
|
||||
@Testcontainers
|
||||
@ -42,4 +54,85 @@ class EtlProcessorApplicationTests : AbstractTestcontainerTest() {
|
||||
assertThat(context).isNotNull
|
||||
}
|
||||
|
||||
@Nested
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
|
||||
@AutoConfigureMockMvc
|
||||
@TestPropertySource(
|
||||
properties = [
|
||||
"app.transformations[0].path=diagnoses[*].icd10.version",
|
||||
"app.transformations[0].from=2013",
|
||||
"app.transformations[0].to=2014",
|
||||
]
|
||||
)
|
||||
inner class TransformationTest {
|
||||
|
||||
@MockBean
|
||||
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<MtbFileSender.MtbFileRequest>())
|
||||
|
||||
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<MtbFileSender.MtbFileRequest>()
|
||||
verify(mtbFileSender).send(captor.capture())
|
||||
assertThat(captor.firstValue.mtbFile.diagnoses).hasSize(1).allMatch { diagnosis ->
|
||||
diagnosis.icd10.version == "2014"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -31,13 +31,13 @@ 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.test.context.ContextConfiguration
|
||||
import org.springframework.test.context.TestPropertySource
|
||||
|
||||
@SpringBootTest
|
||||
@ContextConfiguration(classes = [KafkaAutoConfiguration::class, AppKafkaConfiguration::class, AppRestConfiguration::class])
|
||||
@ContextConfiguration(classes = [AppConfiguration::class, KafkaAutoConfiguration::class, AppKafkaConfiguration::class, AppRestConfiguration::class])
|
||||
@MockBean(ObjectMapper::class)
|
||||
class AppConfigurationTest {
|
||||
|
||||
@Nested
|
||||
@ -65,10 +65,7 @@ class AppConfigurationTest {
|
||||
"app.kafka.group-id=test"
|
||||
]
|
||||
)
|
||||
@MockBeans(value = [
|
||||
MockBean(ObjectMapper::class),
|
||||
MockBean(RequestRepository::class)
|
||||
])
|
||||
@MockBean(RequestRepository::class)
|
||||
inner class AppConfigurationKafkaTest(private val context: ApplicationContext) {
|
||||
|
||||
@Test
|
||||
@ -99,4 +96,24 @@ class AppConfigurationTest {
|
||||
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
@ -127,7 +127,21 @@ public class GpasPseudonymGenerator implements Generator {
|
||||
.orElseGet(ParametersParameterComponent::new).getValue();
|
||||
|
||||
// pseudonym
|
||||
return identifier.getSystem() + "|" + identifier.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, "_");
|
||||
}
|
||||
|
||||
|
||||
|
@ -24,7 +24,8 @@ import org.springframework.boot.context.properties.ConfigurationProperties
|
||||
@ConfigurationProperties(AppConfigProperties.NAME)
|
||||
data class AppConfigProperties(
|
||||
var bwhcUri: String?,
|
||||
var generator: PseudonymGenerator = PseudonymGenerator.BUILDIN
|
||||
var generator: PseudonymGenerator = PseudonymGenerator.BUILDIN,
|
||||
var transformations: List<TransformationProperties> = listOf()
|
||||
) {
|
||||
companion object {
|
||||
const val NAME = "app"
|
||||
@ -79,3 +80,9 @@ enum class PseudonymGenerator {
|
||||
BUILDIN,
|
||||
GPAS
|
||||
}
|
||||
|
||||
data class TransformationProperties(
|
||||
val path: String,
|
||||
val from: String,
|
||||
val to: String
|
||||
)
|
@ -25,6 +25,9 @@ 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.services.Transformation
|
||||
import dev.dnpm.etl.processor.services.TransformationService
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties
|
||||
import org.springframework.context.annotation.Bean
|
||||
@ -41,6 +44,8 @@ import reactor.core.publisher.Sinks
|
||||
)
|
||||
class AppConfiguration {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(AppConfiguration::class.java)
|
||||
|
||||
@ConditionalOnProperty(value = ["app.pseudonymizer"], havingValue = "GPAS")
|
||||
@Bean
|
||||
fun gpasPseudonymGenerator(configProperties: GPasConfigProperties): Generator {
|
||||
@ -71,5 +76,16 @@ class AppConfiguration {
|
||||
return Sinks.many().multicast().directBestEffort()
|
||||
}
|
||||
|
||||
@Bean
|
||||
fun transformationService(
|
||||
objectMapper: ObjectMapper,
|
||||
configProperties: AppConfigProperties
|
||||
): TransformationService {
|
||||
logger.info("Apply ${configProperties.transformations.size} transformation rules")
|
||||
return TransformationService(objectMapper, configProperties.transformations.map {
|
||||
Transformation.of(it.path) from it.from to it.to
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
@ -34,7 +34,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'")
|
||||
@ -56,5 +59,6 @@ class ReportService(
|
||||
enum class Severity(@JsonValue val value: String) {
|
||||
ERROR("error"),
|
||||
WARNING("warning"),
|
||||
INFO("info")
|
||||
}
|
||||
}
|
@ -35,7 +35,10 @@ infix fun MtbFile.pseudonymizeWith(pseudonymizeService: PseudonymizeService) {
|
||||
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.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 } }
|
||||
@ -45,6 +48,6 @@ infix fun MtbFile.pseudonymizeWith(pseudonymizeService: PseudonymizeService) {
|
||||
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.studyInclusionRequests.forEach { it.patient = patientPseudonym }
|
||||
this.specimens.forEach { it.patient = patientPseudonym }
|
||||
}
|
@ -38,6 +38,7 @@ 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,
|
||||
@ -50,7 +51,7 @@ class RequestProcessor(
|
||||
|
||||
mtbFile pseudonymizeWith pseudonymizeService
|
||||
|
||||
val request = MtbFileSender.MtbFileRequest(requestId, mtbFile)
|
||||
val request = MtbFileSender.MtbFileRequest(requestId, transformationService.transform(mtbFile))
|
||||
|
||||
requestService.save(
|
||||
Request(
|
||||
|
@ -0,0 +1,85 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
class TransformationService(private val objectMapper: ObjectMapper, private val transformations: List<Transformation>) {
|
||||
fun transform(mtbFile: MtbFile): MtbFile {
|
||||
var json = objectMapper.writeValueAsString(mtbFile)
|
||||
|
||||
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 objectMapper.readValue(json, MtbFile::class.java)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,41 @@
|
||||
/*
|
||||
* 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.services.TransformationService
|
||||
import org.springframework.stereotype.Controller
|
||||
import org.springframework.ui.Model
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
|
||||
@Controller
|
||||
@RequestMapping(path = ["transformations"])
|
||||
class TransformationController(
|
||||
private val transformationService: TransformationService
|
||||
) {
|
||||
|
||||
@GetMapping
|
||||
fun index(model: Model): String {
|
||||
model.addAttribute("transformations", transformationService.getTransformations())
|
||||
|
||||
return "transformations"
|
||||
}
|
||||
|
||||
}
|
@ -1,3 +1,24 @@
|
||||
:root {
|
||||
--table-border: rgba(96, 96, 96, 1);
|
||||
|
||||
--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);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: sans-serif;
|
||||
@ -57,7 +78,7 @@ nav > ul > li:first-of-type {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.breadcrumps ul li+li:before {
|
||||
.breadcrumps ul li + li:before {
|
||||
padding: .4rem;
|
||||
color: gray;
|
||||
content: "/\00a0";
|
||||
@ -68,6 +89,10 @@ nav > ul > li:first-of-type {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.centered {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
main {
|
||||
margin: 0 auto;
|
||||
max-width: 1140px;
|
||||
@ -115,8 +140,8 @@ form.samplecode-input input:focus-visible {
|
||||
}
|
||||
|
||||
table {
|
||||
border-top: 1px solid lightgray;
|
||||
border-left: 1px solid lightgray;
|
||||
border-top: 1px solid var(--table-border);
|
||||
border-left: 1px solid var(--table-border);
|
||||
border-spacing: 0;
|
||||
border-radius: 3px;
|
||||
|
||||
@ -145,10 +170,10 @@ th {
|
||||
}
|
||||
|
||||
td, th {
|
||||
padding: .2rem;
|
||||
padding: 0.4rem .2rem;
|
||||
|
||||
border-right: 1px solid lightgray;
|
||||
border-bottom: 1px solid lightgray;
|
||||
border-right: 1px solid var(--table-border);
|
||||
border-bottom: 1px solid var(--table-border);
|
||||
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
@ -159,26 +184,66 @@ td {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
td.bg-green, th.bg-green {
|
||||
background: green;
|
||||
td.bg-blue, th.bg-blue {
|
||||
background: var(--bg-blue);
|
||||
color: white;
|
||||
}
|
||||
|
||||
tr:has(td.bg-blue) {
|
||||
background: var(--bg-blue-op);
|
||||
}
|
||||
|
||||
td.bg-green, th.bg-green {
|
||||
background: var(--bg-green);
|
||||
color: white;
|
||||
}
|
||||
|
||||
tr:has(td.bg-green) {
|
||||
background: var(--bg-green-op);
|
||||
}
|
||||
|
||||
td.bg-yellow, th.bg-yellow {
|
||||
background: darkorange;
|
||||
background: var(--bg-yellow);
|
||||
color: white;
|
||||
}
|
||||
|
||||
tr:has(td.bg-yellow) {
|
||||
background: var(--bg-yellow-op);
|
||||
}
|
||||
|
||||
td.bg-red, th.bg-red {
|
||||
background: red;
|
||||
background: var(--bg-red);
|
||||
color: white;
|
||||
}
|
||||
|
||||
tr:has(td.bg-red) {
|
||||
background: var(--bg-red-op);
|
||||
}
|
||||
|
||||
td.bg-gray, th.bg-gray {
|
||||
background: slategray;
|
||||
background: var(--bg-gray);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.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 {
|
||||
background: repeating-linear-gradient(140deg, white, #e5e5f5 4px, white 8px);
|
||||
}
|
||||
@ -279,7 +344,7 @@ input.inline:focus-visible {
|
||||
padding: 1rem;
|
||||
margin: .2rem;
|
||||
|
||||
border: 1px solid lightgray;
|
||||
border: 1px solid var(--table-border);
|
||||
border-radius: 3px;
|
||||
|
||||
width: calc(100% - 2.4rem - 4px);
|
||||
|
@ -10,6 +10,7 @@
|
||||
<ul>
|
||||
<li><a th:href="@{/}">Übersicht</a></li>
|
||||
<li><a th:href="@{/statistics}">Statistiken</a></li>
|
||||
<li><a th:href="@{/transformations}">Transformationen</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
@ -45,6 +45,7 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr th:each="issue : ${issues}">
|
||||
<td th:if="${issue.severity.value == 'info'}" class="bg-blue"><small>[[ ${issue.severity} ]]</small></td>
|
||||
<td th:if="${issue.severity.value == 'warning'}" class="bg-yellow"><small>[[ ${issue.severity} ]]</small></td>
|
||||
<td th:if="${issue.severity.value == 'error'}" class="bg-red"><small>[[ ${issue.severity} ]]</small></td>
|
||||
<td>[[ ${issue.message} ]]</td>
|
||||
|
47
src/main/resources/templates/transformations.html
Normal file
47
src/main/resources/templates/transformations.html
Normal file
@ -0,0 +1,47 @@
|
||||
<!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>Transformationen</h1>
|
||||
|
||||
<h2>Syntax</h2>
|
||||
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>
|
||||
|
||||
<h2>Konfigurierte Transformationen</h2>
|
||||
<p>
|
||||
Hier sehen Sie eine Übersicht der konfigurierten Transformationen.
|
||||
</p>
|
||||
|
||||
<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>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
@ -0,0 +1,64 @@
|
||||
/*
|
||||
* 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.pseudonym
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import de.ukw.ccc.bwhc.dto.MtbFile
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.extension.ExtendWith
|
||||
import org.mockito.ArgumentMatchers
|
||||
import org.mockito.Mock
|
||||
import org.mockito.junit.jupiter.MockitoExtension
|
||||
import org.mockito.kotlin.doAnswer
|
||||
import org.mockito.kotlin.whenever
|
||||
import org.springframework.core.io.ClassPathResource
|
||||
|
||||
const val FAKE_MTB_FILE_PATH = "fake_MTBFile.json"
|
||||
const val CLEAN_PATIENT_ID = "5dad2f0b-49c6-47d8-a952-7b9e9e0f7549"
|
||||
|
||||
@ExtendWith(MockitoExtension::class)
|
||||
class ExtensionsTest {
|
||||
|
||||
private fun fakeMtbFile(): MtbFile {
|
||||
val mtbFile = ClassPathResource(FAKE_MTB_FILE_PATH).inputStream
|
||||
return ObjectMapper().readValue(mtbFile, MtbFile::class.java)
|
||||
}
|
||||
|
||||
private fun MtbFile.serialized(): String {
|
||||
return ObjectMapper().writeValueAsString(this)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldNotContainCleanPatientId(@Mock pseudonymizeService: PseudonymizeService) {
|
||||
doAnswer {
|
||||
it.arguments[0]
|
||||
"PSEUDO-ID"
|
||||
}.whenever(pseudonymizeService).patientPseudonym(ArgumentMatchers.anyString())
|
||||
|
||||
val mtbFile = fakeMtbFile()
|
||||
|
||||
mtbFile.pseudonymizeWith(pseudonymizeService)
|
||||
|
||||
assertThat(mtbFile.patient.id).isEqualTo("PSEUDO-ID")
|
||||
assertThat(mtbFile.serialized()).doesNotContain(CLEAN_PATIENT_ID)
|
||||
}
|
||||
|
||||
}
|
@ -70,6 +70,13 @@ class PseudonymizeServiceTest {
|
||||
assertThat(mtbFile.patient.id).isEqualTo("123")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sanitizeFileName(@Mock generator: GpasPseudonymGenerator) {
|
||||
val result= GpasPseudonymGenerator.sanitizeValue("l://a\\bs;1*2?3>")
|
||||
|
||||
assertThat(result).isEqualTo("l___a_bs_1_2_3_")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldUsePseudonymPrefixForBuiltin(@Mock generator: AnonymizingGenerator) {
|
||||
doAnswer {
|
||||
|
@ -41,6 +41,7 @@ class ReportServiceTest {
|
||||
{
|
||||
"patient": "4711",
|
||||
"issues": [
|
||||
{ "severity": "info", "message": "Info Message" },
|
||||
{ "severity": "warning", "message": "Warning Message" },
|
||||
{ "severity": "error", "message": "Error Message" }
|
||||
]
|
||||
@ -49,11 +50,13 @@ class ReportServiceTest {
|
||||
|
||||
val actual = this.reportService.deserialize(json)
|
||||
|
||||
assertThat(actual).hasSize(2)
|
||||
assertThat(actual[0].severity).isEqualTo(ReportService.Severity.WARNING)
|
||||
assertThat(actual[0].message).isEqualTo("Warning Message")
|
||||
assertThat(actual[1].severity).isEqualTo(ReportService.Severity.ERROR)
|
||||
assertThat(actual[1].message).isEqualTo("Error Message")
|
||||
assertThat(actual).hasSize(3)
|
||||
assertThat(actual[0].severity).isEqualTo(ReportService.Severity.ERROR)
|
||||
assertThat(actual[0].message).isEqualTo("Error Message")
|
||||
assertThat(actual[1].severity).isEqualTo(ReportService.Severity.WARNING)
|
||||
assertThat(actual[1].message).isEqualTo("Warning Message")
|
||||
assertThat(actual[2].severity).isEqualTo(ReportService.Severity.INFO)
|
||||
assertThat(actual[2].message).isEqualTo("Info Message")
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -37,6 +37,7 @@ import org.mockito.Mockito.*
|
||||
import org.mockito.junit.jupiter.MockitoExtension
|
||||
import org.mockito.kotlin.any
|
||||
import org.mockito.kotlin.argumentCaptor
|
||||
import org.mockito.kotlin.whenever
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import java.time.Instant
|
||||
import java.util.*
|
||||
@ -46,6 +47,7 @@ import java.util.*
|
||||
class RequestProcessorTest {
|
||||
|
||||
private lateinit var pseudonymizeService: PseudonymizeService
|
||||
private lateinit var transformationService: TransformationService
|
||||
private lateinit var sender: MtbFileSender
|
||||
private lateinit var requestService: RequestService
|
||||
private lateinit var applicationEventPublisher: ApplicationEventPublisher
|
||||
@ -55,11 +57,13 @@ class RequestProcessorTest {
|
||||
@BeforeEach
|
||||
fun setup(
|
||||
@Mock pseudonymizeService: PseudonymizeService,
|
||||
@Mock transformationService: TransformationService,
|
||||
@Mock sender: RestMtbFileSender,
|
||||
@Mock requestService: RequestService,
|
||||
@Mock applicationEventPublisher: ApplicationEventPublisher
|
||||
) {
|
||||
this.pseudonymizeService = pseudonymizeService
|
||||
this.transformationService = transformationService
|
||||
this.sender = sender
|
||||
this.requestService = requestService
|
||||
this.applicationEventPublisher = applicationEventPublisher
|
||||
@ -68,6 +72,7 @@ class RequestProcessorTest {
|
||||
|
||||
requestProcessor = RequestProcessor(
|
||||
pseudonymizeService,
|
||||
transformationService,
|
||||
sender,
|
||||
requestService,
|
||||
objectMapper,
|
||||
@ -98,6 +103,10 @@ class RequestProcessorTest {
|
||||
it.arguments[0] as String
|
||||
}.`when`(pseudonymizeService).patientPseudonym(any())
|
||||
|
||||
doAnswer {
|
||||
it.arguments[0]
|
||||
}.whenever(transformationService).transform(any())
|
||||
|
||||
val mtbFile = MtbFile.builder()
|
||||
.withPatient(
|
||||
Patient.builder()
|
||||
@ -153,6 +162,10 @@ class RequestProcessorTest {
|
||||
it.arguments[0] as String
|
||||
}.`when`(pseudonymizeService).patientPseudonym(any())
|
||||
|
||||
doAnswer {
|
||||
it.arguments[0]
|
||||
}.whenever(transformationService).transform(any())
|
||||
|
||||
val mtbFile = MtbFile.builder()
|
||||
.withPatient(
|
||||
Patient.builder()
|
||||
@ -212,6 +225,10 @@ class RequestProcessorTest {
|
||||
it.arguments[0] as String
|
||||
}.`when`(pseudonymizeService).patientPseudonym(any())
|
||||
|
||||
doAnswer {
|
||||
it.arguments[0]
|
||||
}.whenever(transformationService).transform(any())
|
||||
|
||||
val mtbFile = MtbFile.builder()
|
||||
.withPatient(
|
||||
Patient.builder()
|
||||
@ -271,6 +288,10 @@ class RequestProcessorTest {
|
||||
it.arguments[0] as String
|
||||
}.`when`(pseudonymizeService).patientPseudonym(any())
|
||||
|
||||
doAnswer {
|
||||
it.arguments[0]
|
||||
}.whenever(transformationService).transform(any())
|
||||
|
||||
val mtbFile = MtbFile.builder()
|
||||
.withPatient(
|
||||
Patient.builder()
|
||||
|
@ -0,0 +1,95 @@
|
||||
/*
|
||||
* 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 de.ukw.ccc.bwhc.dto.Consent
|
||||
import de.ukw.ccc.bwhc.dto.Diagnosis
|
||||
import de.ukw.ccc.bwhc.dto.Icd10
|
||||
import de.ukw.ccc.bwhc.dto.MtbFile
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class TransformationServiceTest {
|
||||
|
||||
private lateinit var service: TransformationService
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
this.service = TransformationService(
|
||||
ObjectMapper(), listOf(
|
||||
Transformation.of("consent.status") from Consent.Status.ACTIVE to Consent.Status.REJECTED,
|
||||
Transformation.of("diagnoses[*].icd10.version") from "2013" to "2014",
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldTransformMtbFile() {
|
||||
val mtbFile = MtbFile.builder().withDiagnoses(
|
||||
listOf(
|
||||
Diagnosis.builder().withId("1234").withIcd10(Icd10("F79.9").also {
|
||||
it.version = "2013"
|
||||
}).build()
|
||||
)
|
||||
).build()
|
||||
|
||||
val actual = this.service.transform(mtbFile)
|
||||
|
||||
assertThat(actual).isNotNull
|
||||
assertThat(actual.diagnoses[0].icd10.version).isEqualTo("2014")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldOnlyTransformGivenValues() {
|
||||
val mtbFile = MtbFile.builder().withDiagnoses(
|
||||
listOf(
|
||||
Diagnosis.builder().withId("1234").withIcd10(Icd10("F79.9").also {
|
||||
it.version = "2013"
|
||||
}).build(),
|
||||
Diagnosis.builder().withId("5678").withIcd10(Icd10("F79.8").also {
|
||||
it.version = "2019"
|
||||
}).build()
|
||||
)
|
||||
).build()
|
||||
|
||||
val actual = this.service.transform(mtbFile)
|
||||
|
||||
assertThat(actual).isNotNull
|
||||
assertThat(actual.diagnoses[0].icd10.code).isEqualTo("F79.9")
|
||||
assertThat(actual.diagnoses[0].icd10.version).isEqualTo("2014")
|
||||
assertThat(actual.diagnoses[1].icd10.code).isEqualTo("F79.8")
|
||||
assertThat(actual.diagnoses[1].icd10.version).isEqualTo("2019")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldTransformMtbFileWithConsentEnum() {
|
||||
val mtbFile = MtbFile.builder().withConsent(
|
||||
Consent("123", "456", Consent.Status.ACTIVE)
|
||||
).build()
|
||||
|
||||
val actual = this.service.transform(mtbFile)
|
||||
|
||||
assertThat(actual.consent).isNotNull
|
||||
assertThat(actual.consent.status).isEqualTo(Consent.Status.REJECTED)
|
||||
}
|
||||
|
||||
}
|
1
src/test/resources/fake_MTBFile.json
Normal file
1
src/test/resources/fake_MTBFile.json
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user