1
0
mirror of https://github.com/pcvolkmer/mv64e-etl-processor synced 2025-09-13 17:02:52 +00:00

63 check consent status (#120)

Co-authored-by: Paul-Christian Volkmer <code@pcvolkmer.de>
This commit is contained in:
jlidke
2025-07-22 20:02:15 +02:00
committed by GitHub
parent 1319be8b3f
commit 199511e567
46 changed files with 6110 additions and 254 deletions

View File

@@ -0,0 +1,31 @@
package dev.dnpm.etl.processor.consent;
import java.util.Date;
import org.hl7.fhir.r4.model.Bundle;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class ConsentByMtbFile implements IGetConsent {
private static final Logger log = LoggerFactory.getLogger(ConsentByMtbFile.class);
public ConsentByMtbFile() {
log.info("ConsentCheckFileBased initialized...");
}
@Override
public TtpConsentStatus getTtpBroadConsentStatus(String personIdentifierValue) {
return TtpConsentStatus.UNKNOWN_CHECK_FILE;
}
/**
* EMPTY METHOD: NOT IMPLEMENTED
*
* @return empty bundle
*/
@Override
public Bundle getConsent(String personIdentifierValue, Date requestDate,
ConsentDomain consentDomain) {
return new Bundle();
}
}

View File

@@ -0,0 +1,13 @@
package dev.dnpm.etl.processor.consent;
public enum ConsentDomain {
/**
* MII Broad consent
*/
BroadConsent,
/**
* GenomDe Modelvohaben §64e
*/
Modelvorhaben64e
}

View File

@@ -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.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 IGetConsent {
private final Logger log = LoggerFactory.getLogger(GicsConsentService.class);
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 final GIcsConfigProperties gIcsConfigProperties;
private String url;
public GicsConsentService(GIcsConfigProperties gIcsConfigProperties,
RetryTemplate retryTemplate, RestTemplate restTemplate, AppFhirConfig appFhirConfig) {
this.retryTemplate = retryTemplate;
this.restTemplate = restTemplate;
this.fhirContext = appFhirConfig.fhirContext();
httpHeader = buildHeader(gIcsConfigProperties.getUsername(),
gIcsConfigProperties.getPassword());
this.gIcsConfigProperties = gIcsConfigProperties;
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(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.getBroadConsentPolicyCode())
.setSystem(configProperties.getBroadConsentPolicySystem())));
/*
* 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 getTtpBroadConsentStatus(String personIdentifierValue) {
var parameter = GicsConsentService.getIsConsentedRequestParam(gIcsConfigProperties,
personIdentifierValue);
var consentStatusResponse = callGicsApi(parameter,
GicsConsentService.IS_CONSENTED_ENDPOINT);
return evaluateConsentResponse(consentStatusResponse);
}
protected 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.");
}
var iBaseResource = fhirContext.newJsonParser()
.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.getGenomDeConsentDomainName();
default -> throw new IllegalArgumentException(
"target ConsentDomain is missing but must be provided!");
}
return consentDomain;
}
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.BROAD_CONSENT_GIVEN;
} else {
return TtpConsentStatus.BROAD_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;
}
@Override
public Bundle getConsent(String patientId, Date requestDate, ConsentDomain consentDomain) {
switch (consentDomain) {
case BroadConsent -> {
return currentConsentForPersonAndTemplate(patientId, ConsentDomain.BroadConsent,
requestDate);
}
case Modelvorhaben64e -> {
return currentConsentForPersonAndTemplate(patientId,
ConsentDomain.Modelvorhaben64e, requestDate);
}
}
return new Bundle();
}
}

View File

@@ -0,0 +1,27 @@
package dev.dnpm.etl.processor.consent;
import java.util.Date;
import org.hl7.fhir.r4.model.Bundle;
public interface IGetConsent {
/**
* Get broad consent status for a patient identifier
*
* @param personIdentifierValue patient identifier used for consent data
* @return status of broad consent
* @apiNote cannot not differ between not asked and rejected
*
*/
TtpConsentStatus getTtpBroadConsentStatus(String personIdentifierValue);
/**
* Get broad consent policies with respect to a request date
*
* @param personIdentifierValue patient identifier used for consent data
* @param requestDate target date until consent data should be considered
* @return consent policies as bundle; <p>if empty patient has not been asked, yet.</p>
*/
Bundle getConsent(String personIdentifierValue, Date requestDate, ConsentDomain consentDomain);
}

View File

@@ -0,0 +1,38 @@
package dev.dnpm.etl.processor.consent;
public enum TtpConsentStatus {
/**
* Valid consent found
*/
BROAD_CONSENT_GIVEN,
/**
* Missing or rejected...actually unknown
*/
BROAD_CONSENT_MISSING_OR_REJECTED,
/**
* No Broad consent policy found
*/
BROAD_CONSENT_MISSING,
/**
* Research policy has been rejected
*/
BROAD_CONSENT_REJECTED,
GENOM_DE_CONSENT_SEQUENCING_PERMIT,
/**
* No GenomDE consent policy found
*/
GENOM_DE_CONSENT_MISSING,
/**
* GenomDE consent policy found, but has been rejected
*/
GENOM_DE_SEQUENCING_REJECTED,
/**
* Consent status is validate via file property 'consent.status'
*/
UNKNOWN_CHECK_FILE,
/**
* Due technical problems consent status is unknown
*/
FAILED_TO_ASK
}

View File

@@ -21,6 +21,7 @@ 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.hl7.fhir.r4.model.Identifier;
@@ -32,11 +33,14 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.*;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.web.client.HttpClientErrorException.BadRequest;
import org.springframework.web.client.HttpClientErrorException.Unauthorized;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate;
public class GpasPseudonymGenerator implements Generator {
private final static FhirContext r4Context = FhirContext.forR4();
private final FhirContext r4Context;
private final String gPasUrl;
private final String psnTargetDomain;
private final HttpHeaders httpHeader;
@@ -45,11 +49,13 @@ public class GpasPseudonymGenerator implements Generator {
private final RestTemplate restTemplate;
public GpasPseudonymGenerator(GPasConfigProperties gpasCfg, RetryTemplate retryTemplate, 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());
log.debug(String.format("%s has been initialized", this.getClass().getName()));
@@ -61,7 +67,7 @@ public class GpasPseudonymGenerator implements Generator {
var gPasRequestBody = getGpasRequestBody(id);
var responseEntity = getGpasPseudonym(gPasRequestBody);
var gPasPseudonymResult = (Parameters) r4Context.newJsonParser()
.parseResource(responseEntity.getBody());
.parseResource(responseEntity.getBody());
return unwrapPseudonym(gPasPseudonymResult);
}
@@ -75,9 +81,9 @@ public class GpasPseudonymGenerator implements Generator {
}
final var identifier = (Identifier) parameters.get().getPart().stream()
.filter(a -> a.getName().equals("pseudonym"))
.findFirst()
.orElseGet(ParametersParameterComponent::new).getValue();
.filter(a -> a.getName().equals("pseudonym"))
.findFirst()
.orElseGet(ParametersParameterComponent::new).getValue();
// pseudonym
return sanitizeValue(identifier.getValue());
@@ -97,38 +103,48 @@ public class GpasPseudonymGenerator implements Generator {
return psnValue.replaceAll(forbiddenCharsRegex, "_");
}
@NotNull
protected ResponseEntity<String> getGpasPseudonym(String gPasRequestBody) {
HttpEntity<String> requestEntity = new HttpEntity<>(gPasRequestBody, this.httpHeader);
ResponseEntity<String> responseEntity;
try {
responseEntity = retryTemplate.execute(
ctx -> restTemplate.exchange(gPasUrl, HttpMethod.POST, requestEntity,
String.class));
ResponseEntity<String> responseEntity = retryTemplate.execute(
ctx -> restTemplate.exchange(gPasUrl, HttpMethod.POST, requestEntity,
String.class));
if (responseEntity.getStatusCode().is2xxSuccessful()) {
log.debug("API request succeeded. Response: {}", responseEntity.getStatusCode());
} else {
log.warn("API request unsuccessful. Response: {}", requestEntity.getBody());
throw new PseudonymRequestFailed("API request unsuccessful gPas unsuccessful.");
return responseEntity;
}
} catch (RestClientException rce) {
if (rce instanceof BadRequest) {
String msg = "gPas or request configuration is incorrect. Please check both."
+ rce.getMessage();
log.debug(
msg);
throw new PseudonymRequestFailed(msg, rce);
}
if (rce instanceof Unauthorized) {
var msg = "gPas access credentials are invalid check your configuration. msg: '%s".formatted(
rce.getMessage());
log.error(msg);
throw new PseudonymRequestFailed(msg, rce);
}
return responseEntity;
} catch (Exception unexpected) {
throw new PseudonymRequestFailed(
"API request due unexpected error unsuccessful gPas unsuccessful.", unexpected);
"API request due unexpected error unsuccessful gPas unsuccessful.", unexpected);
}
throw new PseudonymRequestFailed(
"API request due unexpected error unsuccessful gPas unsuccessful.");
}
protected String getGpasRequestBody(String id) {
var requestParameters = new Parameters();
requestParameters.addParameter().setName("target")
.setValue(new StringType().setValue(psnTargetDomain));
.setValue(new StringType().setValue(psnTargetDomain));
requestParameters.addParameter().setName("original")
.setValue(new StringType().setValue(id));
.setValue(new StringType().setValue(id));
final IParser iParser = r4Context.newJsonParser();
return iParser.encodeResourceToString(requestParameters);
}

View File

@@ -27,7 +27,8 @@ data class AppConfigProperties(
var bwhcUri: String?,
var transformations: List<TransformationProperties> = listOf(),
var maxRetryAttempts: Int = 3,
var duplicationDetection: Boolean = true
var duplicationDetection: Boolean = true,
var genomDeTestSubmission: Boolean = true
) {
companion object {
const val NAME = "app"
@@ -56,6 +57,72 @@ data class GPasConfigProperties(
}
}
@ConfigurationProperties(ConsentConfigProperties.NAME)
data class ConsentConfigProperties(
var service: ConsentService = ConsentService.NONE
) {
companion object {
const val NAME = "app.consent"
}
}
@ConfigurationProperties(GIcsConfigProperties.NAME)
data class GIcsConfigProperties(
/**
* Base URL to gICS System
*
*/
val uri: String?,
val username: String?,
val password: String?,
/**
* 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 genomDeConsentDomainName: String = "GenomDE_MV",
/**
* Value to expect in case of positiv consent
*/
val broadConsentPolicyCode: String = "2.16.840.1.113883.3.1937.777.24.5.3.6",
/**
* Consent Policy which should be used for consent check
*/
val broadConsentPolicySystem: String = "urn:oid:2.16.840.1.113883.3.1937.777.24.5.3",
/**
* Value to expect in case of positiv consent
*/
val genomeDePolicyCode: String = "sequencing",
/**
* Consent Policy which should be used for consent check
*/
val genomeDePolicySystem: String = "https://ths-greifswald.de/fhir/CodeSystem/gics/Policy/GenomDE_MV",
/**
* Consent version (fixed version)
*
*/
val genomeDeConsentVersion: String = "2.0"
) {
companion object {
const val NAME = "app.consent.gics"
}
}
@ConfigurationProperties(RestTargetProperties.NAME)
data class RestTargetProperties(
val uri: String?,
@@ -99,8 +166,13 @@ enum class PseudonymGenerator {
GPAS
}
enum class ConsentService {
NONE,
GICS
}
data class TransformationProperties(
val path: String,
val from: String,
val to: String
)
)

View File

@@ -20,24 +20,28 @@
package dev.dnpm.etl.processor.config
import com.fasterxml.jackson.databind.ObjectMapper
import dev.dnpm.etl.processor.monitoring.ConnectionCheckResult
import dev.dnpm.etl.processor.monitoring.ConnectionCheckService
import dev.dnpm.etl.processor.monitoring.GPasConnectionCheckService
import dev.dnpm.etl.processor.monitoring.ReportService
import dev.dnpm.etl.processor.consent.ConsentByMtbFile
import dev.dnpm.etl.processor.consent.GicsConsentService
import dev.dnpm.etl.processor.consent.IGetConsent
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.ConsentProcessor
import dev.dnpm.etl.processor.services.Transformation
import dev.dnpm.etl.processor.services.TransformationService
import org.slf4j.LoggerFactory
import org.springframework.boot.autoconfigure.condition.AnyNestedCondition
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.Conditional
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.ConfigurationCondition
import org.springframework.data.jdbc.repository.config.AbstractJdbcConfiguration
import org.springframework.retry.RetryCallback
import org.springframework.retry.RetryContext
@@ -60,7 +64,9 @@ import kotlin.time.toJavaDuration
value = [
AppConfigProperties::class,
PseudonymizeConfigProperties::class,
GPasConfigProperties::class
GPasConfigProperties::class,
ConsentConfigProperties::class,
GIcsConfigProperties::class
]
)
@EnableScheduling
@@ -73,13 +79,27 @@ class AppConfiguration {
return RestTemplate()
}
@ConditionalOnProperty(value = ["app.pseudonymize.generator"], havingValue = "GPAS")
@Bean
fun gpasPseudonymGenerator(configProperties: GPasConfigProperties, retryTemplate: RetryTemplate, restTemplate: RestTemplate): Generator {
return GpasPseudonymGenerator(configProperties, retryTemplate, restTemplate)
fun appFhirConfig(): AppFhirConfig {
return AppFhirConfig()
}
@ConditionalOnProperty(value = ["app.pseudonymize.generator"], havingValue = "BUILDIN", matchIfMissing = true)
@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()
@@ -94,17 +114,21 @@ 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(
objectMapper: ObjectMapper,
configProperties: AppConfigProperties
): TransformationService {
logger.info("Apply ${configProperties.transformations.size} transformation rules")
return TransformationService(objectMapper, configProperties.transformations.map {
return TransformationService(getObjectMapper(), configProperties.transformations.map {
Transformation.of(it.path) from it.from to it.to
})
}
@@ -123,7 +147,11 @@ class AppConfiguration {
callback: RetryCallback<T, E>,
throwable: Throwable
) {
logger.warn("Error occured: {}. Retrying {}", throwable.message, context.retryCount)
logger.warn(
"Error occured: {}. Retrying {}",
throwable.message,
context.retryCount
)
}
})
.build()
@@ -131,7 +159,11 @@ class AppConfiguration {
@ConditionalOnProperty(value = ["app.security.enable-tokens"], havingValue = "true")
@Bean
fun tokenService(userDetailsManager: InMemoryUserDetailsManager, passwordEncoder: PasswordEncoder, tokenRepository: TokenRepository): TokenService {
fun tokenService(
userDetailsManager: InMemoryUserDetailsManager,
passwordEncoder: PasswordEncoder,
tokenRepository: TokenRepository
): TokenService {
return TokenService(userDetailsManager, passwordEncoder, tokenRepository)
}
@@ -152,7 +184,11 @@ class AppConfiguration {
gPasConfigProperties: GPasConfigProperties,
connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult>
): ConnectionCheckService {
return GPasConnectionCheckService(restTemplate, gPasConfigProperties, connectionCheckUpdateProducer)
return GPasConnectionCheckService(
restTemplate,
gPasConfigProperties,
connectionCheckUpdateProducer
)
}
@ConditionalOnProperty(value = ["app.pseudonymizer"], havingValue = "GPAS")
@@ -163,12 +199,85 @@ class AppConfiguration {
gPasConfigProperties: GPasConfigProperties,
connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult>
): ConnectionCheckService {
return GPasConnectionCheckService(restTemplate, gPasConfigProperties, connectionCheckUpdateProducer)
return GPasConnectionCheckService(
restTemplate,
gPasConfigProperties,
connectionCheckUpdateProducer
)
}
@Bean
fun jdbcConfiguration(): AbstractJdbcConfiguration {
return AppJdbcConfiguration()
}
@Conditional(GicsEnabledCondition::class)
@Bean
fun gicsConsentService(
gIcsConfigProperties: GIcsConfigProperties,
retryTemplate: RetryTemplate,
restTemplate: RestTemplate,
appFhirConfig: AppFhirConfig
): IGetConsent {
return GicsConsentService(
gIcsConfigProperties,
retryTemplate,
restTemplate,
appFhirConfig
)
}
@Conditional(GicsEnabledCondition::class)
@Bean
fun consentProcessor(
configProperties: AppConfigProperties,
gIcsConfigProperties: GIcsConfigProperties,
getObjectMapper: ObjectMapper,
appFhirConfig: AppFhirConfig,
gicsConsentService: IGetConsent
): ConsentProcessor {
return ConsentProcessor(
configProperties,
gIcsConfigProperties,
getObjectMapper,
appFhirConfig.fhirContext(),
gicsConsentService
)
}
@Conditional(GicsEnabledCondition::class)
@Bean
fun gIcsConnectionCheckService(
restTemplate: RestTemplate,
gIcsConfigProperties: GIcsConfigProperties,
connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult>
): ConnectionCheckService {
return GIcsConnectionCheckService(
restTemplate,
gIcsConfigProperties,
connectionCheckUpdateProducer
)
}
@Bean
@ConditionalOnMissingBean
fun iGetConsentService(): IGetConsent {
return ConsentByMtbFile()
}
}
class GicsEnabledCondition :
AnyNestedCondition(ConfigurationCondition.ConfigurationPhase.REGISTER_BEAN) {
@ConditionalOnProperty(name = ["app.consent.service"], havingValue = "gics")
class OnGicsServiceSelected {
// Just for Condition
}
@ConditionalOnProperty(name = ["app.consent.gics.enabled"], havingValue = "true")
class OnGicsEnabled {
// Just for Condition
}
}

View File

@@ -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
}
}

View File

@@ -0,0 +1,18 @@
package dev.dnpm.etl.processor.config
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.r4.model.Consent
class ConsentResourceDeserializer : JsonDeserializer<Consent>() {
override fun deserialize(p: JsonParser?, ctxt: DeserializationContext?): Consent {
val jsonNode = p?.readValueAsTree<JsonNode>()
val json = jsonNode?.toString()
return JacksonConfig.fhirContext().newJsonParser().parseResource(json) as Consent
}
}

View File

@@ -0,0 +1,15 @@
package dev.dnpm.etl.processor.config
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.databind.JsonSerializer
import com.fasterxml.jackson.databind.SerializerProvider
import org.hl7.fhir.r4.model.Consent
class ConsentResourceSerializer : JsonSerializer<Consent>() {
override fun serialize(
value: Consent, gen: JsonGenerator, serializers: SerializerProvider
) {
val json = JacksonConfig.fhirContext().newJsonParser().encodeResourceToString(value)
gen.writeRawValue(json)
}
}

View File

@@ -0,0 +1,12 @@
package dev.dnpm.etl.processor.config
import com.fasterxml.jackson.databind.module.SimpleModule
import org.hl7.fhir.r4.model.Consent
class FhirResourceModule : SimpleModule() {
init {
addSerializer(Consent::class.java, ConsentResourceSerializer())
addDeserializer(Consent::class.java, ConsentResourceDeserializer())
}
}

View File

@@ -0,0 +1,27 @@
package dev.dnpm.etl.processor.config
import ca.uhn.fhir.context.FhirContext
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 {
companion object {
var fhirContext: FhirContext = FhirContext.forR4()
@JvmStatic
fun fhirContext(): FhirContext {
return fhirContext
}
}
@Bean
fun objectMapper(): ObjectMapper = ObjectMapper().registerModule(FhirResourceModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS).registerModule(
JavaTimeModule()
)
}

View File

@@ -25,6 +25,7 @@ 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
@@ -76,9 +77,13 @@ class KafkaInputListener(
} else {
logger.debug("Accepted MTB File and process deletion")
if (requestId.isBlank()) {
requestProcessor.processDeletion(patientId)
requestProcessor.processDeletion(patientId, TtpConsentStatus.UNKNOWN_CHECK_FILE)
} else {
requestProcessor.processDeletion(patientId, requestId)
requestProcessor.processDeletion(
patientId,
requestId,
TtpConsentStatus.UNKNOWN_CHECK_FILE
)
}
}
}

View File

@@ -23,6 +23,8 @@ 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.IGetConsent
import dev.dnpm.etl.processor.consent.TtpConsentStatus
import dev.dnpm.etl.processor.services.RequestProcessor
import dev.pcvolkmer.mv64e.mtb.Mtb
import org.slf4j.LoggerFactory
@@ -33,7 +35,7 @@ import org.springframework.web.bind.annotation.*
@RestController
@RequestMapping(path = ["mtbfile", "mtb"])
class MtbFileRestController(
private val requestProcessor: RequestProcessor,
private val requestProcessor: RequestProcessor, private val iGetConsent: IGetConsent
) {
private val logger = LoggerFactory.getLogger(MtbFileRestController::class.java)
@@ -43,20 +45,39 @@ class MtbFileRestController(
return ResponseEntity.ok("Test")
}
@PostMapping( consumes = [ MediaType.APPLICATION_JSON_VALUE ] )
@PostMapping(consumes = [MediaType.APPLICATION_JSON_VALUE])
fun mtbFile(@RequestBody mtbFile: MtbFile): ResponseEntity<Unit> {
if (mtbFile.consent.status == Consent.Status.ACTIVE) {
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)
requestProcessor.processDeletion(patientId, ttpConsentStatus)
}
return ResponseEntity.accepted().build()
}
@PostMapping( consumes = [ CustomMediaType.APPLICATION_VND_DNPM_V2_MTB_JSON_VALUE] )
private fun checkConsentStatus(mtbFile: MtbFile): Pair<TtpConsentStatus, Boolean> {
var ttpConsentStatus = iGetConsent.getTtpBroadConsentStatus(mtbFile.patient.id)
val isConsentOK =
(ttpConsentStatus.equals(TtpConsentStatus.UNKNOWN_CHECK_FILE) && mtbFile.consent.status == Consent.Status.ACTIVE) ||
ttpConsentStatus.equals(
TtpConsentStatus.BROAD_CONSENT_GIVEN
)
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.BROAD_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)
@@ -66,7 +87,7 @@ class MtbFileRestController(
@DeleteMapping(path = ["{patientId}"])
fun deleteData(@PathVariable patientId: String): ResponseEntity<Unit> {
logger.debug("Accepted patient ID to process deletion")
requestProcessor.processDeletion(PatientId(patientId))
requestProcessor.processDeletion(PatientId(patientId), TtpConsentStatus.UNKNOWN_CHECK_FILE)
return ResponseEntity.accepted().build()
}

View File

@@ -20,6 +20,7 @@
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
@@ -68,6 +69,12 @@ sealed class ConnectionCheckResult {
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(
@@ -207,4 +214,57 @@ class GPasConnectionCheckService(
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
}
}

View File

@@ -24,5 +24,6 @@ enum class RequestStatus(val value: String) {
WARNING("warning"),
ERROR("error"),
UNKNOWN("unknown"),
DUPLICATION("duplication")
DUPLICATION("duplication"),
NO_CONSENT("no-consent")
}

View File

@@ -44,13 +44,20 @@ class KafkaMtbFileSender(
return try {
return retryTemplate.execute<MtbFileSender.Response, Exception> {
val record =
ProducerRecord(kafkaProperties.outputTopic, key(request), objectMapper.writeValueAsString(request))
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())
.add(
"contentType",
CustomMediaType.APPLICATION_VND_DNPM_V2_MTB_JSON_VALUE.toByteArray()
)
}
val result = kafkaTemplate.send(record)
@@ -84,7 +91,12 @@ class KafkaMtbFileSender(
kafkaProperties.outputTopic,
key(request),
// Always use old BwhcV1FileRequest with Consent REJECT
objectMapper.writeValueAsString(BwhcV1MtbFileRequest(request.requestId, dummyMtbFile))
objectMapper.writeValueAsString(
BwhcV1MtbFileRequest(
request.requestId,
dummyMtbFile
)
)
)
val result = kafkaTemplate.send(record)

View File

@@ -21,7 +21,9 @@ package dev.dnpm.etl.processor.pseudonym
import de.ukw.ccc.bwhc.dto.MtbFile
import dev.dnpm.etl.processor.PatientId
import dev.pcvolkmer.mv64e.mtb.ModelProjectConsent
import dev.pcvolkmer.mv64e.mtb.Mtb
import dev.pcvolkmer.mv64e.mtb.MvhMetadata
import org.apache.commons.codec.digest.DigestUtils
/** Replaces patient ID with generated patient pseudonym
@@ -289,6 +291,16 @@ infix fun Mtb.pseudonymizeWith(pseudonymizeService: PseudonymizeService) {
this.followUps?.forEach {
it.patient.id = patientPseudonym
}
this.metadata?.researchConsents?.forEach { it ->
val entry = it ?: return@forEach
if (entry.contains("patient")) {
// here we expect only a patient reference any other data like display
// need to be removed, since may contain unsecure data
entry.remove("patient")
entry["patient"] = mapOf("reference" to "Patient/$patientPseudonym")
}
}
}
/**
@@ -317,3 +329,23 @@ infix fun Mtb.anonymizeContentWith(pseudonymizeService: PseudonymizeService) {
// TODO all other properties
}
fun Mtb.ensureMetaDataIsInitialized() {
// init metadata if necessary
if (this.metadata == null) {
val mvhMetadata = MvhMetadata.builder().build()
this.metadata = mvhMetadata
}
if (this.metadata.researchConsents == null) {
this.metadata.researchConsents = mutableListOf()
}
if (this.metadata.modelProjectConsent == null) {
this.metadata.modelProjectConsent = ModelProjectConsent()
this.metadata.modelProjectConsent.provisions = mutableListOf()
} else
if (this.metadata.modelProjectConsent.provisions != null) {
// make sure list can be changed
this.metadata.modelProjectConsent.provisions =
this.metadata.modelProjectConsent.provisions.toMutableList()
}
}

View File

@@ -0,0 +1,282 @@
package dev.dnpm.etl.processor.services
import ca.uhn.fhir.context.FhirContext
import com.fasterxml.jackson.core.JsonProcessingException
import com.fasterxml.jackson.core.type.TypeReference
import com.fasterxml.jackson.databind.ObjectMapper
import dev.dnpm.etl.processor.config.AppConfigProperties
import dev.dnpm.etl.processor.config.GIcsConfigProperties
import dev.dnpm.etl.processor.consent.ConsentByMtbFile
import dev.dnpm.etl.processor.consent.ConsentDomain
import dev.dnpm.etl.processor.consent.IGetConsent
import dev.dnpm.etl.processor.pseudonym.ensureMetaDataIsInitialized
import dev.pcvolkmer.mv64e.mtb.*
import org.apache.commons.lang3.NotImplementedException
import org.hl7.fhir.r4.model.*
import org.hl7.fhir.r4.model.Bundle.BundleEntryComponent
import org.hl7.fhir.r4.model.Coding
import org.hl7.fhir.r4.model.Consent.ConsentState
import org.hl7.fhir.r4.model.Consent.ProvisionComponent
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.io.IOException
import java.time.Clock
import java.time.Instant
import java.util.*
@Service
class ConsentProcessor(
private val appConfigProperties: AppConfigProperties,
private val gIcsConfigProperties: GIcsConfigProperties,
private val objectMapper: ObjectMapper,
private val fhirContext: FhirContext,
private val consentService: IGetConsent
) {
private var logger: Logger = LoggerFactory.getLogger("ConsentProcessor")
/**
* In case an instance of {@link ICheckConsent} is active, consent will be embedded and checked.
*
* Logik:
* * <c>true</c> IF consent check is disabled.
* * <c>true</c> IF broad consent (BC) has been given.
* * <c>true</c> BC has been asked AND declined but genomDe consent has been consented.
* * ELSE <c>false</c> is returned.
*
* @param mtbFile File v2 (will be enriched with consent data)
* @return true if consent is given
*
*/
fun consentGatedCheckAndTryEmbedding(mtbFile: Mtb): Boolean {
if (consentService is ConsentByMtbFile) {
// consent check is disabled
return true
}
mtbFile.ensureMetaDataIsInitialized()
val personIdentifierValue = mtbFile.patient.id
val requestDate = Date.from(Instant.now(Clock.systemUTC()))
// 1. Broad consent Entry exists?
// 1.1. -> yes and research consent is given -> send mtb file
// 1.2. -> no -> return status error - consent has not been asked
// 2. -> Broad consent found but rejected -> is GenomDe consent provision 'sequencing' given?
// 2.1 -> yes -> send mtb file
// 2.2 -> no -> warn/info no consent given
/*
* broad consent
*/
val broadConsent = consentService.getConsent(
personIdentifierValue, requestDate, ConsentDomain.BroadConsent
)
val broadConsentHasBeenAsked = !broadConsent.entry.isEmpty()
// fast exit - if patient has not been asked, we can skip and exit
if (!broadConsentHasBeenAsked) return false
val genomeDeConsent = consentService.getConsent(
personIdentifierValue, requestDate, ConsentDomain.Modelvorhaben64e
)
addGenomeDbProvisions(mtbFile, genomeDeConsent)
if (!genomeDeConsent.entry.isEmpty()) setGenomDeSubmissionType(mtbFile)
embedBroadConsentResources(mtbFile, broadConsent)
val broadConsentStatus = getProvisionTypeByPolicyCode(
broadConsent, requestDate, ConsentDomain.BroadConsent
)
val genomDeSequencingStatus = getProvisionTypeByPolicyCode(
genomeDeConsent, requestDate, ConsentDomain.Modelvorhaben64e
)
if (Consent.ConsentProvisionType.NULL == broadConsentStatus) {
// bc not asked
return false
}
if (Consent.ConsentProvisionType.PERMIT == broadConsentStatus || Consent.ConsentProvisionType.PERMIT == genomDeSequencingStatus) return true
return false
}
fun embedBroadConsentResources(mtbFile: Mtb, broadConsent: Bundle) {
for (entry in broadConsent.getEntry()) {
val resource = entry.getResource()
if (resource is Consent) {
// since jackson convertValue does not work here,
// we need another step to back to string, before we convert to object map
val asJsonString = fhirContext.newJsonParser().encodeResourceToString(resource)
try {
val mapOfJson: HashMap<String?, Any?>? =
objectMapper.readValue<HashMap<String?, Any?>?>(
asJsonString, object : TypeReference<HashMap<String?, Any?>?>() {})
mtbFile.metadata.researchConsents.add(mapOfJson)
} catch (e: JsonProcessingException) {
throw RuntimeException(e)
}
}
}
}
fun addGenomeDbProvisions(mtbFile: Mtb, consentGnomeDe: Bundle) {
for (entry in consentGnomeDe.getEntry()) {
val resource = entry.getResource()
if (resource !is Consent) {
continue
}
// We expect only one provision in collection, therefore get first or none
val provisions = resource.getProvision().getProvision()
if (provisions.isEmpty()) {
continue
}
val provisionComponent: ProvisionComponent = provisions.first()
var provisionCode: String? = null
if (provisionComponent.getCode() != null && !provisionComponent.getCode().isEmpty()) {
val codableConcept: CodeableConcept = provisionComponent.getCode().first()
if (codableConcept.getCoding() != null && !codableConcept.getCoding().isEmpty()) {
provisionCode = codableConcept.getCoding().first().getCode()
}
}
if (provisionCode != null) {
try {
val modelProjectConsentPurpose =
ModelProjectConsentPurpose.forValue(provisionCode)
if (ModelProjectConsentPurpose.SEQUENCING == modelProjectConsentPurpose) {
// CONVENTION: wrapping date is date of SEQUENCING consent
mtbFile.metadata.modelProjectConsent.date = resource.getDateTime()
}
val provision = Provision.builder()
.type(ConsentProvision.valueOf(provisionComponent.getType().name))
.date(provisionComponent.getPeriod().getStart())
.purpose(modelProjectConsentPurpose).build()
mtbFile.metadata.modelProjectConsent.provisions.add(provision)
} catch (ioe: IOException) {
logger.error(
"Provision code '$provisionCode' is unknown and cannot be mapped.",
ioe.toString()
)
}
}
if (!mtbFile.metadata.modelProjectConsent.provisions.isEmpty()) {
mtbFile.metadata.modelProjectConsent.version =
gIcsConfigProperties.genomeDeConsentVersion
}
}
}
/**
* fixme: currently we do not have information about submission type
*/
private fun setGenomDeSubmissionType(mtbFile: Mtb) {
if (appConfigProperties.genomDeTestSubmission) {
// fixme: remove INITIAL and uncomment when data model is updated
mtbFile.metadata.type = MvhSubmissionType.INITIAL
// mtbFile.metadata.type = MvhSubmissionType.Test
logger.info("genomeDe submission mit TEST")
} else {
mtbFile.metadata.type = MvhSubmissionType.INITIAL
}
}
/**
* @param consentBundle consent resource
* @param requestDate date which must be within validation period of provision
* @return type of provision, will be [org.hl7.fhir.r4.model.Consent.ConsentProvisionType.NULL] if none is found.
*/
fun getProvisionTypeByPolicyCode(
consentBundle: Bundle, requestDate: Date?, consentDomain: ConsentDomain
): Consent.ConsentProvisionType {
val code: String?
val system: String?
if (ConsentDomain.BroadConsent == consentDomain) {
code = gIcsConfigProperties.broadConsentPolicyCode
system = gIcsConfigProperties.broadConsentPolicySystem
} else if (ConsentDomain.Modelvorhaben64e == consentDomain) {
code = gIcsConfigProperties.genomeDePolicyCode
system = gIcsConfigProperties.genomeDePolicySystem
} else {
throw NotImplementedException("unknown consent domain " + consentDomain.name)
}
val provisionTypeByPolicyCode = getProvisionTypeByPolicyCode(
consentBundle, code, system, requestDate
)
return provisionTypeByPolicyCode
}
/**
* @param consentBundle consent resource
* @param policyAndProvisionCode policyRule and provision code value
* @param policyAndProvisionSystem policyRule and provision system value
* @param requestDate date which must be within validation period of provision
* @return type of provision, will be [org.hl7.fhir.r4.model.Consent.ConsentProvisionType.NULL] if none is found.
*/
fun getProvisionTypeByPolicyCode(
consentBundle: Bundle,
policyAndProvisionCode: String?,
policyAndProvisionSystem: String?,
requestDate: Date?
): Consent.ConsentProvisionType {
val entriesOfInterest = consentBundle.entry.filter { entry ->
entry.resource.isResource && entry.resource.resourceType == ResourceType.Consent && (entry.resource as Consent).status == ConsentState.ACTIVE && checkCoding(
policyAndProvisionCode,
policyAndProvisionSystem,
(entry.resource as Consent).policyRule.codingFirstRep
) && isIsRequestDateInRange(
requestDate, (entry.resource as Consent).provision.period
)
}.map { consentWithTargetPolicy: BundleEntryComponent ->
val provision = (consentWithTargetPolicy.getResource() as Consent).getProvision()
val provisionComponentByCode =
provision.getProvision().stream().filter { prov: ProvisionComponent? ->
checkCoding(
policyAndProvisionCode,
policyAndProvisionSystem,
prov!!.getCodeFirstRep().getCodingFirstRep()
) && isIsRequestDateInRange(
requestDate, prov.getPeriod()
)
}.findFirst()
if (provisionComponentByCode.isPresent) {
// actual provision we search for
return@map provisionComponentByCode.get().getType()
} else {
if (provision.type != null) return provision.type
}
return Consent.ConsentProvisionType.NULL
}.firstOrNull()
if (entriesOfInterest == null) return Consent.ConsentProvisionType.NULL
return entriesOfInterest
}
fun checkCoding(
researchAllowedPolicyOid: String?, researchAllowedPolicySystem: String?, coding: Coding
): Boolean {
return coding.getSystem() == researchAllowedPolicySystem && (coding.getCode() == researchAllowedPolicyOid)
}
fun isIsRequestDateInRange(requestdate: Date?, provPeriod: Period): Boolean {
val isRequestDateAfterOrEqualStart = provPeriod.getStart().compareTo(requestdate)
val isRequestDateBeforeOrEqualEnd = provPeriod.getEnd().compareTo(requestdate)
return isRequestDateAfterOrEqualStart <= 0 && isRequestDateBeforeOrEqualEnd >= 0
}
}

View File

@@ -23,6 +23,7 @@ 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.TtpConsentStatus
import dev.dnpm.etl.processor.monitoring.Report
import dev.dnpm.etl.processor.monitoring.Request
import dev.dnpm.etl.processor.monitoring.RequestStatus
@@ -34,8 +35,11 @@ import dev.dnpm.etl.processor.pseudonym.pseudonymizeWith
import dev.pcvolkmer.mv64e.mtb.Mtb
import org.apache.commons.codec.binary.Base32
import org.apache.commons.codec.digest.DigestUtils
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.context.ApplicationEventPublisher
import org.springframework.stereotype.Service
import java.lang.RuntimeException
import java.time.Instant
import java.util.*
@@ -47,9 +51,11 @@ class RequestProcessor(
private val requestService: RequestService,
private val objectMapper: ObjectMapper,
private val applicationEventPublisher: ApplicationEventPublisher,
private val appConfigProperties: AppConfigProperties
private val appConfigProperties: AppConfigProperties,
private val consentProcessor: ConsentProcessor?
) {
private var logger: Logger = LoggerFactory.getLogger("RequestProcessor")
fun processMtbFile(mtbFile: MtbFile) {
processMtbFile(mtbFile, randomRequestId())
}
@@ -66,12 +72,25 @@ class RequestProcessor(
processMtbFile(mtbFile, randomRequestId())
}
fun processMtbFile(mtbFile: Mtb, requestId: RequestId) {
val pid = PatientId(mtbFile.patient.id)
mtbFile pseudonymizeWith pseudonymizeService
mtbFile anonymizeContentWith pseudonymizeService
val request = DnpmV2MtbFileRequest(requestId, transformationService.transform(mtbFile))
saveAndSend(request, pid)
val pid = PatientId(extractPatientIdentifier(mtbFile))
val isConsentOk = consentProcessor != null &&
consentProcessor.consentGatedCheckAndTryEmbedding(mtbFile) || consentProcessor == null
if (isConsentOk) {
mtbFile pseudonymizeWith pseudonymizeService
mtbFile anonymizeContentWith pseudonymizeService
val request = DnpmV2MtbFileRequest(requestId, transformationService.transform(mtbFile))
saveAndSend(request, pid)
} else {
logger.warn("consent check failed file will not be processed further!")
applicationEventPublisher.publishEvent(
ResponseEvent(
requestId, Instant.now(), RequestStatus.NO_CONSENT
)
)
}
}
private fun <T> saveAndSend(request: MtbFileRequest<T>, pid: PatientId) {
@@ -89,9 +108,7 @@ class RequestProcessor(
if (appConfigProperties.duplicationDetection && isDuplication(request)) {
applicationEventPublisher.publishEvent(
ResponseEvent(
request.requestId,
Instant.now(),
RequestStatus.DUPLICATION
request.requestId, Instant.now(), RequestStatus.DUPLICATION
)
)
return
@@ -120,21 +137,31 @@ class RequestProcessor(
val lastMtbFileRequestForPatient =
requestService.lastMtbFileRequestForPatientPseudonym(patientPseudonym)
val isLastRequestDeletion = requestService.isLastRequestWithKnownStatusDeletion(patientPseudonym)
val isLastRequestDeletion =
requestService.isLastRequestWithKnownStatusDeletion(patientPseudonym)
return null != lastMtbFileRequestForPatient
&& !isLastRequestDeletion
&& lastMtbFileRequestForPatient.fingerprint == fingerprint(pseudonymizedMtbFileRequest)
return null != lastMtbFileRequestForPatient && !isLastRequestDeletion && lastMtbFileRequestForPatient.fingerprint == fingerprint(
pseudonymizedMtbFileRequest
)
}
fun processDeletion(patientId: PatientId) {
processDeletion(patientId, randomRequestId())
fun processDeletion(patientId: PatientId, isConsented: TtpConsentStatus) {
processDeletion(patientId, randomRequestId(), isConsented)
}
fun processDeletion(patientId: PatientId, requestId: RequestId) {
fun processDeletion(patientId: PatientId, requestId: RequestId, isConsented: TtpConsentStatus) {
try {
val patientPseudonym = pseudonymizeService.patientPseudonym(patientId)
val requestStatus: RequestStatus = when (isConsented) {
TtpConsentStatus.BROAD_CONSENT_MISSING_OR_REJECTED, TtpConsentStatus.BROAD_CONSENT_MISSING, TtpConsentStatus.BROAD_CONSENT_REJECTED -> RequestStatus.NO_CONSENT
TtpConsentStatus.FAILED_TO_ASK -> RequestStatus.ERROR
TtpConsentStatus.BROAD_CONSENT_GIVEN, TtpConsentStatus.UNKNOWN_CHECK_FILE -> RequestStatus.UNKNOWN
TtpConsentStatus.GENOM_DE_CONSENT_SEQUENCING_PERMIT, TtpConsentStatus.GENOM_DE_CONSENT_MISSING, TtpConsentStatus.GENOM_DE_SEQUENCING_REJECTED -> {
throw RuntimeException("processDelete should never deal with '" + isConsented.name + "' consent status. This is a bug and need to be fixed!")
}
}
requestService.save(
Request(
requestId,
@@ -142,7 +169,7 @@ class RequestProcessor(
patientId,
fingerprint(patientPseudonym.value),
RequestType.DELETE,
RequestStatus.UNKNOWN
requestStatus
)
)
@@ -150,17 +177,14 @@ class RequestProcessor(
applicationEventPublisher.publishEvent(
ResponseEvent(
requestId,
Instant.now(),
responseStatus.status,
when (responseStatus.status) {
requestId, Instant.now(), responseStatus.status, when (responseStatus.status) {
RequestStatus.WARNING, RequestStatus.ERROR -> Optional.of(responseStatus.body)
else -> Optional.empty()
}
)
)
} catch (e: Exception) {
} catch (_: Exception) {
requestService.save(
Request(
uuid = requestId,
@@ -184,10 +208,10 @@ class RequestProcessor(
private fun fingerprint(s: String): Fingerprint {
return Fingerprint(
Base32().encodeAsString(DigestUtils.sha256(s))
.replace("=", "")
.lowercase()
Base32().encodeAsString(DigestUtils.sha256(s)).replace("=", "").lowercase()
)
}
}
private fun extractPatientIdentifier(mtbFile: Mtb): String = mtbFile.patient.id

View File

@@ -70,6 +70,12 @@ class ResponseProcessor(
)
}
RequestStatus.NO_CONSENT -> {
it.report = Report(
"Einwilligung Status fehlt, widerrufen oder ungeklärt."
)
}
else -> {
logger.error("Cannot process response: Unknown response!")
return@ifPresentOrElse

View File

@@ -19,10 +19,7 @@
package dev.dnpm.etl.processor.web
import dev.dnpm.etl.processor.monitoring.ConnectionCheckResult
import dev.dnpm.etl.processor.monitoring.ConnectionCheckService
import dev.dnpm.etl.processor.monitoring.GPasConnectionCheckService
import dev.dnpm.etl.processor.monitoring.OutputConnectionCheckService
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
@@ -61,11 +58,15 @@ class ConfigController(
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())
@@ -119,6 +120,24 @@ class ConfigController(
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) {
@@ -190,6 +209,7 @@ class ConfigController(
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>()

View File

@@ -16,6 +16,7 @@ spring:
content:
enabled: true
paths: /**/*.js,/**/*.css,/**/*.svg,/**/*.jpeg
app:
isGenomDeTestSubmission: true
server:
forward-headers-strategy: framework

View File

@@ -49,6 +49,11 @@
</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>

View File

@@ -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>
&nbsp;|&nbsp;
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>