diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/Disease.java b/sormas-api/src/main/java/de/symeda/sormas/api/Disease.java index fb02976ba54..b53cb7ee2b5 100644 --- a/sormas-api/src/main/java/de/symeda/sormas/api/Disease.java +++ b/sormas-api/src/main/java/de/symeda/sormas/api/Disease.java @@ -17,6 +17,10 @@ import java.util.Arrays; import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import com.google.common.collect.ImmutableSet; import de.symeda.sormas.api.i18n.I18nProperties; import de.symeda.sormas.api.statistics.StatisticsGroupingKey; @@ -95,6 +99,12 @@ public enum Disease OTHER(true, true, true, false, true, 21, false, false, false, false, 0, 0), UNDEFINED(true, true, true, false, true, 0, false, false, false, false, 0, 0); + /** + * Immutable that eager loads all available diseases. + */ + public static final Set ALL_DISEASES = + Arrays.stream(Disease.values()).collect(Collectors.collectingAndThen(Collectors.toSet(), ImmutableSet::copyOf)); + private final boolean defaultActive; private final boolean defaultPrimary; private final boolean defaultCaseSurveillanceEnabled; diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/FacadeProvider.java b/sormas-api/src/main/java/de/symeda/sormas/api/FacadeProvider.java index 6dc5c60b1e5..dd1cd0bb5ee 100644 --- a/sormas-api/src/main/java/de/symeda/sormas/api/FacadeProvider.java +++ b/sormas-api/src/main/java/de/symeda/sormas/api/FacadeProvider.java @@ -109,6 +109,7 @@ import de.symeda.sormas.api.specialcaseaccess.SpecialCaseAccessFacade; import de.symeda.sormas.api.survey.SurveyFacade; import de.symeda.sormas.api.survey.SurveyTokenFacade; +import de.symeda.sormas.api.survey.alias.PathAliasFacade; import de.symeda.sormas.api.symptoms.SymptomsFacade; import de.symeda.sormas.api.systemconfiguration.SystemConfigurationCategoryFacade; import de.symeda.sormas.api.systemconfiguration.SystemConfigurationValueFacade; @@ -319,6 +320,10 @@ public static PrescriptionFacade getPrescriptionFacade() { return get().lookupEjbRemote(PrescriptionFacade.class); } + public static PathAliasFacade getPathAliasFacade() { + return get().lookupEjbRemote(PathAliasFacade.class); + } + public static TreatmentFacade getTreatmentFacade() { return get().lookupEjbRemote(TreatmentFacade.class); } diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/ResourceBundle.java b/sormas-api/src/main/java/de/symeda/sormas/api/ResourceBundle.java index ad0bb0dfd4a..245bf50031d 100644 --- a/sormas-api/src/main/java/de/symeda/sormas/api/ResourceBundle.java +++ b/sormas-api/src/main/java/de/symeda/sormas/api/ResourceBundle.java @@ -26,4 +26,8 @@ public String getString(String key, String defaultValue) { public String getString(String key) { return getString(key, null); } + + public java.util.ResourceBundle getResourceBundle() { + return resourceBundle; + } } diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/caze/InfectionSetting.java b/sormas-api/src/main/java/de/symeda/sormas/api/caze/InfectionSetting.java index ead866aa3ed..a41be917a7e 100644 --- a/sormas-api/src/main/java/de/symeda/sormas/api/caze/InfectionSetting.java +++ b/sormas-api/src/main/java/de/symeda/sormas/api/caze/InfectionSetting.java @@ -16,9 +16,11 @@ package de.symeda.sormas.api.caze; import de.symeda.sormas.api.i18n.I18nProperties; +import de.symeda.sormas.api.patch.mapping.ValueMapperDefault; public enum InfectionSetting { + @ValueMapperDefault UNKNOWN(null), AMBULATORY(null), MEDICAL_PRACTICE(AMBULATORY), diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/caze/QuarantineReason.java b/sormas-api/src/main/java/de/symeda/sormas/api/caze/QuarantineReason.java index c143186c4dd..1fd5f7013e3 100644 --- a/sormas-api/src/main/java/de/symeda/sormas/api/caze/QuarantineReason.java +++ b/sormas-api/src/main/java/de/symeda/sormas/api/caze/QuarantineReason.java @@ -16,6 +16,7 @@ package de.symeda.sormas.api.caze; import de.symeda.sormas.api.i18n.I18nProperties; +import de.symeda.sormas.api.patch.mapping.ValueMapperDefault; public enum QuarantineReason { @@ -23,6 +24,7 @@ public enum QuarantineReason { ENTRY_FROM_RISK_AREA, SWISS_COVID_APP_NOTIFICATION, OUTBREAK_INVESTIGATION, + @ValueMapperDefault OTHER_REASON; @Override diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/caze/Trimester.java b/sormas-api/src/main/java/de/symeda/sormas/api/caze/Trimester.java index 42afaf93256..fa8e49b6a5b 100644 --- a/sormas-api/src/main/java/de/symeda/sormas/api/caze/Trimester.java +++ b/sormas-api/src/main/java/de/symeda/sormas/api/caze/Trimester.java @@ -1,12 +1,14 @@ package de.symeda.sormas.api.caze; import de.symeda.sormas.api.i18n.I18nProperties; +import de.symeda.sormas.api.patch.mapping.ValueMapperDefault; public enum Trimester { FIRST, SECOND, THIRD, + @ValueMapperDefault UNKNOWN; @Override diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/clinicalcourse/HealthConditionsDto.java b/sormas-api/src/main/java/de/symeda/sormas/api/clinicalcourse/HealthConditionsDto.java index d3f333acf1d..10f8e4ae2a0 100644 --- a/sormas-api/src/main/java/de/symeda/sormas/api/clinicalcourse/HealthConditionsDto.java +++ b/sormas-api/src/main/java/de/symeda/sormas/api/clinicalcourse/HealthConditionsDto.java @@ -84,6 +84,8 @@ public class HealthConditionsDto extends PseudonymizableDto { private YesNoUnknown hivArt; private YesNoUnknown chronicLiverDisease; private YesNoUnknown malignancyChemotherapy; + + //TODO: rename ? general heart issue @HideForCountries(countries = { CountryHelper.COUNTRY_CODE_GERMANY, CountryHelper.COUNTRY_CODE_SWITZERLAND }) diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/externalmessage/ExternalMessageDto.java b/sormas-api/src/main/java/de/symeda/sormas/api/externalmessage/ExternalMessageDto.java index fb0a3643ee8..6c106da573e 100644 --- a/sormas-api/src/main/java/de/symeda/sormas/api/externalmessage/ExternalMessageDto.java +++ b/sormas-api/src/main/java/de/symeda/sormas/api/externalmessage/ExternalMessageDto.java @@ -19,6 +19,7 @@ import java.util.Date; import java.util.List; +import javax.annotation.Nullable; import javax.validation.constraints.Size; import de.symeda.sormas.api.CountryHelper; @@ -33,6 +34,7 @@ import de.symeda.sormas.api.disease.DiseaseVariant; import de.symeda.sormas.api.exposure.ModeOfTransmission; import de.symeda.sormas.api.externalmessage.labmessage.SampleReportDto; +import de.symeda.sormas.api.externalmessage.survey.ExternalSurveyResponseData; import de.symeda.sormas.api.feature.FeatureType; import de.symeda.sormas.api.i18n.Validations; import de.symeda.sormas.api.infrastructure.country.CountryReferenceDto; @@ -206,6 +208,10 @@ public class ExternalMessageDto extends SormasToSormasShareableDto { private String externalMessageDetails; @Size(max = FieldConstraints.CHARACTER_LIMIT_TEXT, message = Validations.textTooLong) private String caseComments; + + /** + * Used as deduplication key for {@link ExternalMessageType#SURVEY_RESPONSE}. + */ @Size(max = FieldConstraints.CHARACTER_LIMIT_DEFAULT, message = Validations.textTooLong) private String reportId; @@ -279,6 +285,12 @@ public class ExternalMessageDto extends SormasToSormasShareableDto { private ModeOfTransmission modeOfTransmission; private String modeOfTransmissionType; + /** + * Will only be present for: {@link ExternalMessageType#SURVEY_RESPONSE} to represent the pair. + */ + @Nullable + private ExternalSurveyResponseData surveyResponseData; + public ExternalMessageType getType() { return type; } @@ -1020,4 +1032,14 @@ public String getModeOfTransmissionType() { public void setModeOfTransmissionType(String modeOfTransmissionType) { this.modeOfTransmissionType = modeOfTransmissionType; } + + @Nullable + public ExternalSurveyResponseData getSurveyResponseData() { + return surveyResponseData; + } + + public ExternalMessageDto setSurveyResponseData(@Nullable ExternalSurveyResponseData surveyResponseData) { + this.surveyResponseData = surveyResponseData; + return this; + } } diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/externalmessage/ExternalMessageFacade.java b/sormas-api/src/main/java/de/symeda/sormas/api/externalmessage/ExternalMessageFacade.java index b8ac0f9a17f..af1315137f1 100644 --- a/sormas-api/src/main/java/de/symeda/sormas/api/externalmessage/ExternalMessageFacade.java +++ b/sormas-api/src/main/java/de/symeda/sormas/api/externalmessage/ExternalMessageFacade.java @@ -2,7 +2,9 @@ import java.util.Date; import java.util.List; +import java.util.Map; +import javax.annotation.Nullable; import javax.ejb.Remote; import javax.naming.NamingException; import javax.validation.Valid; @@ -11,6 +13,7 @@ import de.symeda.sormas.api.ReferenceDto; import de.symeda.sormas.api.caze.surveillancereport.SurveillanceReportReferenceDto; import de.symeda.sormas.api.common.Page; +import de.symeda.sormas.api.patch.partial_retrieval.DisplayablePartialRetrievalResponse; import de.symeda.sormas.api.sample.SampleReferenceDto; import de.symeda.sormas.api.user.UserReferenceDto; import de.symeda.sormas.api.utils.SortProperty; @@ -22,6 +25,19 @@ public interface ExternalMessageFacade extends PermanentlyDeletableFacade { ExternalMessageDto saveAndProcessLabmessage(@Valid ExternalMessageDto dto); + /** + * Will attempt to fetch and refresh. + * + * @param since + * if not specified + * @return external messages that have been saved. + */ + List saveAndProcessSurveyResponses(@Nullable Date since); + + default List saveAndProcessSurveyResponses() { + return saveAndProcessSurveyResponses(null); + } + void validate(ExternalMessageDto dto); // Also returns deleted lab messages @@ -70,4 +86,25 @@ public interface ExternalMessageFacade extends PermanentlyDeletableFacade { boolean existsForwardedExternalMessageWith(String reportId); ExternalMessageDto getForSurveillanceReport(SurveillanceReportReferenceDto surveillanceReport); + + /** + * Re-submits a survey response with a corrected patch dictionary after previous processing failures. + * + * @param uuid + * UUID of the external message (must be of type SURVEY_RESPONSE) + * @param correctedDictionary + * the corrected field path -> value map to apply + * @return updated ExternalMessageDto after reprocessing + */ + ExternalMessageDto overwriteSurveyResponse(String uuid, Map correctedDictionary); + + /** + * Retrieves display-ready field information (translated names and current case values) for all fields + * in the survey response patch dictionary. Used by the UI to render the detail/editor windows. + * + * @param externalMessageUuid + * UUID of the external message (must be of type SURVEY_RESPONSE with a processed result) + * @return displayable field info keyed by field path + */ + DisplayablePartialRetrievalResponse fetchSurveyResponseFieldsForDisplay(String externalMessageUuid); } diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/externalmessage/ExternalMessageType.java b/sormas-api/src/main/java/de/symeda/sormas/api/externalmessage/ExternalMessageType.java index 2a8eb1f1502..af77fc3e659 100644 --- a/sormas-api/src/main/java/de/symeda/sormas/api/externalmessage/ExternalMessageType.java +++ b/sormas-api/src/main/java/de/symeda/sormas/api/externalmessage/ExternalMessageType.java @@ -5,7 +5,8 @@ public enum ExternalMessageType { LAB_MESSAGE, - PHYSICIANS_REPORT; + PHYSICIANS_REPORT, + SURVEY_RESPONSE; @Override public String toString() { diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/externalmessage/survey/ExternalMessageSurveyResponseRequest.java b/sormas-api/src/main/java/de/symeda/sormas/api/externalmessage/survey/ExternalMessageSurveyResponseRequest.java new file mode 100644 index 00000000000..9547cdf160e --- /dev/null +++ b/sormas-api/src/main/java/de/symeda/sormas/api/externalmessage/survey/ExternalMessageSurveyResponseRequest.java @@ -0,0 +1,261 @@ +package de.symeda.sormas.api.externalmessage.survey; + +import java.io.Serializable; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import javax.annotation.Nullable; +import javax.validation.constraints.NotNull; + +import de.symeda.sormas.api.Language; +import de.symeda.sormas.api.externalmessage.ExternalMessageType; +import de.symeda.sormas.api.info.InfoFacade; +import de.symeda.sormas.api.patch.DataReplacementStrategy; +import de.symeda.sormas.api.patch.EmptyValueBehavior; + +/** + * Request object that represents a Patching request from a survey response. + * Will be present for {@link ExternalMessageType#SURVEY_RESPONSE}. + */ +public class ExternalMessageSurveyResponseRequest implements Serializable, Comparable { + + private static final long serialVersionUID = 1L; + + private String token; + private String externalSurveyId; + + private String externalRespondentId; + + private Date responseReceivedDate; + + private boolean patchedInCaseOfFailures = false; + + @NotNull + private DataReplacementStrategy replacementStrategy = DataReplacementStrategy.IF_NOT_ALREADY_PRESENT; + + @NotNull + private EmptyValueBehavior emptyValueBehavior = EmptyValueBehavior.IGNORE; + + /** + * Key are those from with root being the {@link de.symeda.sormas.api.caze.CaseDataDto}. + * The accepted fields are those from {@link InfoFacade#generateDataDictionary()}. + */ + @NotNull + private Map patchDictionary; + + /** + * Contains fields that were excluded from the patch dictionary, meant for fields that may not start with the prefix. + * The prefix allows to safely exclude fields that are not meant to be mapped into SORMAS. + */ + @NotNull + private Map excludedPatchDictionary; + + /** + * Origin that wants the patch operation. + * Can be used within {@link de.symeda.sormas.api.patch.mapping.FieldCustomMapper}. + */ + @Nullable + private String origin; + + /** + * To be able to support I18n inputs the input languages can be passed, system locale by default. + */ + @Nullable + private List inputLanguages; + + /** + * If true, for enumeration-like targetTypes the default value will be used. + * Mostly "OTHER". + */ + private boolean allowFallbackValues = true; + + /** + * Could imagine that the cron in addition to the webhook could process elements twice. + * Set false to force a refresh: data etc. + */ + private boolean skipIfAlreadyProcessed = true; + + public String getToken() { + return token; + } + + public ExternalMessageSurveyResponseRequest setToken(String token) { + this.token = token; + return this; + } + + public String getExternalSurveyId() { + return externalSurveyId; + } + + public ExternalMessageSurveyResponseRequest setExternalSurveyId(String externalSurveyId) { + this.externalSurveyId = externalSurveyId; + return this; + } + + public String getExternalRespondentId() { + return externalRespondentId; + } + + public ExternalMessageSurveyResponseRequest setExternalRespondentId(String externalRespondentId) { + this.externalRespondentId = externalRespondentId; + return this; + } + + public Date getResponseReceivedDate() { + return responseReceivedDate; + } + + public ExternalMessageSurveyResponseRequest setResponseReceivedDate(Date responseReceivedDate) { + this.responseReceivedDate = responseReceivedDate; + return this; + } + + public boolean isPatchedInCaseOfFailures() { + return patchedInCaseOfFailures; + } + + public ExternalMessageSurveyResponseRequest setPatchedInCaseOfFailures(boolean patchedInCaseOfFailures) { + this.patchedInCaseOfFailures = patchedInCaseOfFailures; + return this; + } + + public DataReplacementStrategy getReplacementStrategy() { + return replacementStrategy; + } + + public ExternalMessageSurveyResponseRequest setReplacementStrategy(DataReplacementStrategy replacementStrategy) { + this.replacementStrategy = replacementStrategy; + return this; + } + + public EmptyValueBehavior getEmptyValueBehavior() { + return emptyValueBehavior; + } + + public ExternalMessageSurveyResponseRequest setEmptyValueBehavior(EmptyValueBehavior emptyValueBehavior) { + this.emptyValueBehavior = emptyValueBehavior; + return this; + } + + public Map getPatchDictionary() { + return patchDictionary; + } + + public ExternalMessageSurveyResponseRequest setPatchDictionary(Map patchDictionary) { + this.patchDictionary = patchDictionary; + return this; + } + + @Nullable + public String getOrigin() { + return origin; + } + + public ExternalMessageSurveyResponseRequest setOrigin(@Nullable String origin) { + this.origin = origin; + return this; + } + + @Nullable + public List getInputLanguages() { + return inputLanguages; + } + + public ExternalMessageSurveyResponseRequest setInputLanguages(@Nullable List inputLanguages) { + this.inputLanguages = inputLanguages; + return this; + } + + public boolean isAllowFallbackValues() { + return allowFallbackValues; + } + + public ExternalMessageSurveyResponseRequest setAllowFallbackValues(boolean allowFallbackValues) { + this.allowFallbackValues = allowFallbackValues; + return this; + } + + public boolean isSkipIfAlreadyProcessed() { + return skipIfAlreadyProcessed; + } + + public ExternalMessageSurveyResponseRequest setSkipIfAlreadyProcessed(boolean skipIfAlreadyProcessed) { + this.skipIfAlreadyProcessed = skipIfAlreadyProcessed; + return this; + } + + public Map getExcludedPatchDictionary() { + return excludedPatchDictionary; + } + + public ExternalMessageSurveyResponseRequest setExcludedPatchDictionary(Map excludedPatchDictionary) { + this.excludedPatchDictionary = excludedPatchDictionary; + return this; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) + return false; + ExternalMessageSurveyResponseRequest that = (ExternalMessageSurveyResponseRequest) o; + return patchedInCaseOfFailures == that.patchedInCaseOfFailures + && allowFallbackValues == that.allowFallbackValues + && skipIfAlreadyProcessed == that.skipIfAlreadyProcessed + && Objects.equals(token, that.token) + && Objects.equals(externalSurveyId, that.externalSurveyId) + && Objects.equals(externalRespondentId, that.externalRespondentId) + && Objects.equals(responseReceivedDate, that.responseReceivedDate) + && replacementStrategy == that.replacementStrategy + && emptyValueBehavior == that.emptyValueBehavior + && Objects.equals(patchDictionary, that.patchDictionary) + && Objects.equals(excludedPatchDictionary, that.excludedPatchDictionary) + && Objects.equals(origin, that.origin) + && Objects.equals(inputLanguages, that.inputLanguages); + } + + @Override + public int hashCode() { + return Objects.hash( + token, + externalSurveyId, + externalRespondentId, + responseReceivedDate, + patchedInCaseOfFailures, + replacementStrategy, + emptyValueBehavior, + patchDictionary, + excludedPatchDictionary, + origin, + inputLanguages, + allowFallbackValues, + skipIfAlreadyProcessed); + } + + @Override + public String toString() { + return "ExternalMessageSurveyResponseRequest{" + "token='" + token + '\'' + ", externalSurveyId='" + externalSurveyId + '\'' + + ", externalRespondentId='" + externalRespondentId + '\'' + ", responseReceivedDate=" + responseReceivedDate + + ", patchedInCaseOfFailures=" + patchedInCaseOfFailures + ", replacementStrategy=" + replacementStrategy + ", emptyValueBehavior=" + + emptyValueBehavior + ", patchDictionary=" + patchDictionary + ", excludedPatchDictionary=" + excludedPatchDictionary + ", origin='" + + origin + '\'' + ", inputLanguages=" + inputLanguages + ", allowFallbackValues=" + allowFallbackValues + ", skipIfAlreadyProcessed=" + + skipIfAlreadyProcessed + '}'; + } + + @Override + public int compareTo(ExternalMessageSurveyResponseRequest o) { + + if (this.responseReceivedDate == null && o.responseReceivedDate == null) { + return 0; + } + if (this.responseReceivedDate == null) { + return -1; + } + if (o.responseReceivedDate == null) { + return 1; + } + return this.responseReceivedDate.compareTo(o.responseReceivedDate); + } +} diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/externalmessage/survey/ExternalMessageSurveyResponseResult.java b/sormas-api/src/main/java/de/symeda/sormas/api/externalmessage/survey/ExternalMessageSurveyResponseResult.java new file mode 100644 index 00000000000..2c07e70e89f --- /dev/null +++ b/sormas-api/src/main/java/de/symeda/sormas/api/externalmessage/survey/ExternalMessageSurveyResponseResult.java @@ -0,0 +1,54 @@ +package de.symeda.sormas.api.externalmessage.survey; + +import java.io.Serializable; +import java.util.Objects; + +import de.symeda.sormas.api.patch.DataPatchResponse; + +/** + * Used to represent a patching response from a survey response for a specific case. + */ +public class ExternalMessageSurveyResponseResult implements Serializable { + + private static final long serialVersionUID = 1L; + + private String caseUuid; + + private DataPatchResponse patchResponse; + + public String getCaseUuid() { + return caseUuid; + } + + public ExternalMessageSurveyResponseResult setCaseUuid(String caseUuid) { + this.caseUuid = caseUuid; + return this; + } + + public DataPatchResponse getPatchResponse() { + return patchResponse; + } + + public ExternalMessageSurveyResponseResult setPatchResponse(DataPatchResponse patchResponse) { + this.patchResponse = patchResponse; + return this; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) + return false; + ExternalMessageSurveyResponseResult that = (ExternalMessageSurveyResponseResult) o; + return Objects.equals(caseUuid, that.caseUuid) && Objects.equals(patchResponse, that.patchResponse); + } + + @Override + public int hashCode() { + return Objects.hash(caseUuid, patchResponse); + } + + @Override + public String toString() { + return "ExternalMessageSurveyResponseResult{" + "caseUuid='" + caseUuid + '\'' + ", patchResponse=" + patchResponse + '}'; + } +} diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/externalmessage/survey/ExternalMessageSurveyResponseWrapper.java b/sormas-api/src/main/java/de/symeda/sormas/api/externalmessage/survey/ExternalMessageSurveyResponseWrapper.java new file mode 100644 index 00000000000..628b527428e --- /dev/null +++ b/sormas-api/src/main/java/de/symeda/sormas/api/externalmessage/survey/ExternalMessageSurveyResponseWrapper.java @@ -0,0 +1,51 @@ +package de.symeda.sormas.api.externalmessage.survey; + +import java.io.Serializable; +import java.util.Objects; + +/** + * A survey response creates a request(from the response) -result(from patch-attempt) pair, this wraps this pair. + */ +public class ExternalMessageSurveyResponseWrapper implements Serializable { + + private static final long serialVersionUID = 1L; + + private ExternalMessageSurveyResponseRequest request; + private ExternalMessageSurveyResponseResult result; + + public ExternalMessageSurveyResponseRequest getRequest() { + return request; + } + + public ExternalMessageSurveyResponseWrapper setRequest(ExternalMessageSurveyResponseRequest request) { + this.request = request; + return this; + } + + public ExternalMessageSurveyResponseResult getResult() { + return result; + } + + public ExternalMessageSurveyResponseWrapper setResult(ExternalMessageSurveyResponseResult result) { + this.result = result; + return this; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) + return false; + ExternalMessageSurveyResponseWrapper that = (ExternalMessageSurveyResponseWrapper) o; + return Objects.equals(request, that.request) && Objects.equals(result, that.result); + } + + @Override + public int hashCode() { + return Objects.hash(request, result); + } + + @Override + public String toString() { + return "ExternalMessageSurveyResponseWrapper{" + "request=" + request + ", result=" + result + '}'; + } +} diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/externalmessage/survey/ExternalSurveyResponseData.java b/sormas-api/src/main/java/de/symeda/sormas/api/externalmessage/survey/ExternalSurveyResponseData.java new file mode 100644 index 00000000000..8c338e7540b --- /dev/null +++ b/sormas-api/src/main/java/de/symeda/sormas/api/externalmessage/survey/ExternalSurveyResponseData.java @@ -0,0 +1,74 @@ +package de.symeda.sormas.api.externalmessage.survey; + +import java.io.Serializable; +import java.util.Objects; +import java.util.Optional; + +import javax.annotation.Nullable; +import javax.validation.constraints.NotNull; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +/** + * Some survey mapping might be valid and processed on first try, but for others X-attempts might be required. + * Original will not be mutated but updated will change on each attempt for the give survey-response - case combination. + */ +public class ExternalSurveyResponseData implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * Represents the original mapping contract (request) and response for the survey mapping. + */ + @NotNull + private ExternalMessageSurveyResponseWrapper original; + + /** + * May be present in case the original had failures and a new request-response was specified by some user. + */ + @Nullable + private ExternalMessageSurveyResponseWrapper updated; + + public ExternalMessageSurveyResponseWrapper getOriginal() { + return original; + } + + public ExternalSurveyResponseData setOriginal(ExternalMessageSurveyResponseWrapper original) { + this.original = original; + return this; + } + + @Nullable + public ExternalMessageSurveyResponseWrapper getUpdated() { + return updated; + } + + public ExternalSurveyResponseData setUpdated(@Nullable ExternalMessageSurveyResponseWrapper updated) { + this.updated = updated; + return this; + } + + @NotNull + @JsonIgnore + public ExternalMessageSurveyResponseWrapper getLatest() { + return Optional.ofNullable(updated).orElse(original); + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) + return false; + ExternalSurveyResponseData that = (ExternalSurveyResponseData) o; + return Objects.equals(original, that.original) && Objects.equals(updated, that.updated); + } + + @Override + public int hashCode() { + return Objects.hash(original, updated); + } + + @Override + public String toString() { + return "ExternalSurveyResponseData{" + "original=" + original + ", updated=" + updated + '}'; + } +} diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/externalmessage/survey/SurveyAsExternalMessageAdapterFacade.java b/sormas-api/src/main/java/de/symeda/sormas/api/externalmessage/survey/SurveyAsExternalMessageAdapterFacade.java new file mode 100644 index 00000000000..9b073334add --- /dev/null +++ b/sormas-api/src/main/java/de/symeda/sormas/api/externalmessage/survey/SurveyAsExternalMessageAdapterFacade.java @@ -0,0 +1,15 @@ +package de.symeda.sormas.api.externalmessage.survey; + +import javax.ejb.Remote; + +import de.symeda.sormas.api.externalmessage.ExternalMessageAdapterFacade; + +/** + * A remote interface can only be implemented once, otherwise the build breaks. + * This second interface keeps the same contract, but allows to create a separate entry points for external messages. + * It allows to keep them both independent: separate fetching frequency | robustness: one can fail but not the other. + */ +@Remote +public interface SurveyAsExternalMessageAdapterFacade extends ExternalMessageAdapterFacade { + +} diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/feature/FeatureType.java b/sormas-api/src/main/java/de/symeda/sormas/api/feature/FeatureType.java index fd62ec0ba96..e031f435647 100644 --- a/sormas-api/src/main/java/de/symeda/sormas/api/feature/FeatureType.java +++ b/sormas-api/src/main/java/de/symeda/sormas/api/feature/FeatureType.java @@ -14,18 +14,9 @@ */ package de.symeda.sormas.api.feature; -import static de.symeda.sormas.api.common.DeletableEntityType.CASE; -import static de.symeda.sormas.api.common.DeletableEntityType.CONTACT; -import static de.symeda.sormas.api.common.DeletableEntityType.EVENT; -import static de.symeda.sormas.api.common.DeletableEntityType.EVENT_PARTICIPANT; -import static de.symeda.sormas.api.common.DeletableEntityType.IMMUNIZATION; -import static de.symeda.sormas.api.common.DeletableEntityType.TRAVEL_ENTRY; +import static de.symeda.sormas.api.common.DeletableEntityType.*; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.Map; -import java.util.Set; +import java.util.*; import com.google.common.collect.ImmutableMap; @@ -128,7 +119,13 @@ public enum FeatureType { new FeatureType[] { SAMPLES_LAB }, null, - ImmutableMap.of(FeatureTypeProperty.FETCH_MODE, Boolean.FALSE, FeatureTypeProperty.FORCE_AUTOMATIC_PROCESSING, false)), + ImmutableMap.of( + FeatureTypeProperty.FETCH_MODE, + Boolean.FALSE, + FeatureTypeProperty.FORCE_AUTOMATIC_PROCESSING, + false, + FeatureTypeProperty.SURVEY_FETCH_ENABLED, + false)), MANUAL_EXTERNAL_MESSAGES(true, true, new FeatureType[] { diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/feature/FeatureTypeProperty.java b/sormas-api/src/main/java/de/symeda/sormas/api/feature/FeatureTypeProperty.java index f4c0878e202..8266494dd4d 100644 --- a/sormas-api/src/main/java/de/symeda/sormas/api/feature/FeatureTypeProperty.java +++ b/sormas-api/src/main/java/de/symeda/sormas/api/feature/FeatureTypeProperty.java @@ -30,6 +30,7 @@ public enum FeatureTypeProperty { SHARE_IMMUNIZATIONS(Boolean.class), SHARE_REPORTS(Boolean.class), FETCH_MODE(Boolean.class), + SURVEY_FETCH_ENABLED(Boolean.class), FORCE_AUTOMATIC_PROCESSING(Boolean.class); private final Class returnType; diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/hospitalization/HospitalizationDto.java b/sormas-api/src/main/java/de/symeda/sormas/api/hospitalization/HospitalizationDto.java index fa8b6c81409..128653acf32 100644 --- a/sormas-api/src/main/java/de/symeda/sormas/api/hospitalization/HospitalizationDto.java +++ b/sormas-api/src/main/java/de/symeda/sormas/api/hospitalization/HospitalizationDto.java @@ -29,12 +29,7 @@ import de.symeda.sormas.api.ImportIgnore; import de.symeda.sormas.api.feature.FeatureType; import de.symeda.sormas.api.i18n.Validations; -import de.symeda.sormas.api.utils.DataHelper; -import de.symeda.sormas.api.utils.DependingOnFeatureType; -import de.symeda.sormas.api.utils.Diseases; -import de.symeda.sormas.api.utils.FieldConstraints; -import de.symeda.sormas.api.utils.Outbreaks; -import de.symeda.sormas.api.utils.YesNoUnknown; +import de.symeda.sormas.api.utils.*; @DependingOnFeatureType(featureType = FeatureType.CASE_SURVEILANCE) public class HospitalizationDto extends EntityDto { diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/i18n/Captions.java b/sormas-api/src/main/java/de/symeda/sormas/api/i18n/Captions.java index 71ee50fe7e5..46a9f65a9e9 100644 --- a/sormas-api/src/main/java/de/symeda/sormas/api/i18n/Captions.java +++ b/sormas-api/src/main/java/de/symeda/sormas/api/i18n/Captions.java @@ -60,6 +60,7 @@ public interface Captions { String actionConfirmAction = "actionConfirmAction"; String actionConfirmFilters = "actionConfirmFilters"; String actionContinue = "actionContinue"; + String actionCorrectAndReprocess = "actionCorrectAndReprocess"; String actionCreate = "actionCreate"; String actionCreatingLabel = "actionCreatingLabel"; String actionDearchiveCoreEntity = "actionDearchiveCoreEntity"; @@ -120,6 +121,7 @@ public interface Captions { String actionSaveAndOpenContact = "actionSaveAndOpenContact"; String actionSaveAndOpenEventParticipant = "actionSaveAndOpenEventParticipant"; String actionSaveAndOpenHospitalization = "actionSaveAndOpenHospitalization"; + String actionSaveAndReprocess = "actionSaveAndReprocess"; String actionSaveChanges = "actionSaveChanges"; String actionSearch = "actionSearch"; String actionSelectAll = "actionSelectAll"; @@ -1980,6 +1982,7 @@ public interface Captions { String externalEmailSentBy = "externalEmailSentBy"; String externalEmailSentTo = "externalEmailSentTo"; String externalEmailUsedTemplate = "externalEmailUsedTemplate"; + String externalId = "externalId"; String ExternalMessage = "ExternalMessage"; String ExternalMessage_assignee = "ExternalMessage.assignee"; String ExternalMessage_caseReportDate = "ExternalMessage.caseReportDate"; @@ -2943,9 +2946,33 @@ public interface Captions { String surveillanceReportNoReportsForCase = "surveillanceReportNoReportsForCase"; String SurveyDocumentOptions_recipientEmail = "SurveyDocumentOptions.recipientEmail"; String SurveyDocumentOptions_survey = "SurveyDocumentOptions.survey"; + String surveyFetch = "surveyFetch"; String surveyGenerate = "surveyGenerate"; String surveyNew = "surveyNew"; String surveyNewSurvey = "surveyNewSurvey"; + String surveyResponseApplied = "surveyResponseApplied"; + String surveyResponseCaseLink = "surveyResponseCaseLink"; + String surveyResponseCurrentCaseValue = "surveyResponseCurrentCaseValue"; + String surveyResponseDescription = "surveyResponseDescription"; + String surveyResponseEmptyValueBehavior = "surveyResponseEmptyValueBehavior"; + String surveyResponseExcludedFieldsDictionary = "surveyResponseExcludedFieldsDictionary"; + String surveyResponseExternalSurveyId = "surveyResponseExternalSurveyId"; + String surveyResponseFailureCause = "surveyResponseFailureCause"; + String surveyResponseField = "surveyResponseField"; + String surveyResponseGeneralInfo = "surveyResponseGeneralInfo"; + String surveyResponseIgnoreField = "surveyResponseIgnoreField"; + String surveyResponseKeyName = "surveyResponseKeyName"; + String surveyResponseMetadata = "surveyResponseMetadata"; + String surveyResponsePatchDictionary = "surveyResponsePatchDictionary"; + String surveyResponsePatchedInCaseOfFailures = "surveyResponsePatchedInCaseOfFailures"; + String surveyResponseProcessingResult = "surveyResponseProcessingResult"; + String surveyResponseReplacementStrategy = "surveyResponseReplacementStrategy"; + String surveyResponseRespondentId = "surveyResponseRespondentId"; + String surveyResponseResponseReceivedDate = "surveyResponseResponseReceivedDate"; + String surveyResponseSubmittedValue = "surveyResponseSubmittedValue"; + String surveyResponseToken = "surveyResponseToken"; + String surveyResponseUuid = "surveyResponseUuid"; + String surveyResponseValidFields = "surveyResponseValidFields"; String surveySend = "surveySend"; String surveySurveyList = "surveySurveyList"; String surveySurveyTokenList = "surveySurveyTokenList"; diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/i18n/I18nProperties.java b/sormas-api/src/main/java/de/symeda/sormas/api/i18n/I18nProperties.java index d117ce5dc41..3a6f48691e8 100644 --- a/sormas-api/src/main/java/de/symeda/sormas/api/i18n/I18nProperties.java +++ b/sormas-api/src/main/java/de/symeda/sormas/api/i18n/I18nProperties.java @@ -26,8 +26,11 @@ import java.util.HashMap; import java.util.Locale; import java.util.Map; +import java.util.Optional; import java.util.PropertyResourceBundle; import java.util.ResourceBundle.Control; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; import org.apache.commons.lang3.StringUtils; import org.apache.commons.text.StringSubstitutor; @@ -54,6 +57,11 @@ public final class I18nProperties { private final ResourceBundle continentProperties; private final ResourceBundle subcontinentProperties; + /** + * Used internally when bundle type is specified so extract the value from the adequate properties file. + */ + private final Map resourceBundlesDictionary; + private static I18nProperties getInstance(Language language) { if (language == null) { @@ -372,6 +380,24 @@ private I18nProperties(Language language) { this.countryProperties = loadProperties("countries", language.getLocale()); this.continentProperties = loadProperties("continents", language.getLocale()); this.subcontinentProperties = loadProperties("subcontinents", language.getLocale()); + + resourceBundlesDictionary = Map.of( + I18nPropertiesRequest.ResourceBundleType.CAPTION, + captionProperties, + I18nPropertiesRequest.ResourceBundleType.DESCRIPTION, + descriptionProperties, + I18nPropertiesRequest.ResourceBundleType.ENUMS, + enumProperties, + I18nPropertiesRequest.ResourceBundleType.VALIDATION, + validationProperties, + I18nPropertiesRequest.ResourceBundleType.STRING, + stringProperties, + I18nPropertiesRequest.ResourceBundleType.COUNTRY, + countryProperties, + I18nPropertiesRequest.ResourceBundleType.CONTINENT, + continentProperties, + I18nPropertiesRequest.ResourceBundleType.SUBCONTINENT, + subcontinentProperties); } private I18nProperties() { @@ -407,6 +433,43 @@ public static ResourceBundle loadProperties(String propertiesGroup, Locale local return new ResourceBundle(java.util.ResourceBundle.getBundle(propertiesGroup, locale, new UTF8Control())); } + /** + * Meant to be used for "matching purposes", you have a value, but you don't know which ResourceBundle / translationType it belongs to. + * Implemented on a best-effort basis. + * + * @param request + * to return the adequate dictionary. + * @return dictionary with key being the "physical value" of the property key and value the + * translated value in the specified language. + * Example: (key: value) continent.AUSTRALIA.name: Australia (Continent) -> AUSTRALIA: Australia (Continent) + */ + public static Map buildKeyValueDictionary(I18nPropertiesRequest request) { + + Language language = request.getLanguage() != null ? request.getLanguage() : Language.EN; + I18nProperties instance = getInstance(language); + + I18nPropertiesRequest.ResourceBundleType resourceBundleType = request.getResourceBundleType(); + ResourceBundle resourceBundle = Optional.of(instance.resourceBundlesDictionary.get(resourceBundleType)) + .orElseThrow(() -> new IllegalStateException(String.format("Resource bundle type [%s] not found", resourceBundleType))); + + Class targetType = request.getTargetType(); + + return StreamSupport.stream(((Iterable) () -> resourceBundle.getResourceBundle().getKeys().asIterator()).spliterator(), false) + .filter(key -> StringUtils.startsWith(key, buildEnumPrefix(targetType, resourceBundleType))) + .collect( + Collectors.toMap( + key -> key.replace(I18nProperties.buildEnumPrefix(targetType, resourceBundleType), "").replace(".name", ""), + resourceBundle::getString)); + } + + private static String buildEnumPrefix(Class targetType, I18nPropertiesRequest.ResourceBundleType resourceBundleType) { + if (resourceBundleType == I18nPropertiesRequest.ResourceBundleType.COUNTRY) { + return "country."; + } + + return targetType.getSimpleName() + "."; + } + public static class UTF8Control extends Control { private static final char LOCALE_SEP = '-'; @@ -431,8 +494,8 @@ public java.util.ResourceBundle newBundle(String baseName, Locale locale, String * Converts the given baseName and locale * to the bundle name. This method is called from the default * implementation of the {@link #newBundle(String, Locale, String, - * ClassLoader, boolean) newBundle} and {@link #needsReload(String, - * Locale, String, ClassLoader, ResourceBundle, long) needsReload} + * ClassLoader, boolean) newBundle} and {@link java.util.ResourceBundle}#needsReload(String, Locale, String, ClassLoader, + * ResourceBundle, long) needsReload} * methods. * *

diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/i18n/I18nPropertiesRequest.java b/sormas-api/src/main/java/de/symeda/sormas/api/i18n/I18nPropertiesRequest.java new file mode 100644 index 00000000000..8f7ec0ef145 --- /dev/null +++ b/sormas-api/src/main/java/de/symeda/sormas/api/i18n/I18nPropertiesRequest.java @@ -0,0 +1,82 @@ +package de.symeda.sormas.api.i18n; + +import java.util.Objects; + +import javax.annotation.Nullable; +import javax.validation.constraints.NotNull; + +import de.symeda.sormas.api.Language; + +public class I18nPropertiesRequest { + + /** + * If not provided will fallback to default language. + */ + @Nullable + private Language language; + + @NotNull + private ResourceBundleType resourceBundleType; + + @NotNull + private Class targetType; + + @Nullable + public Language getLanguage() { + return language; + } + + public I18nPropertiesRequest setLanguage(@Nullable Language language) { + this.language = language; + return this; + } + + public ResourceBundleType getResourceBundleType() { + return resourceBundleType; + } + + public I18nPropertiesRequest setResourceBundleType(ResourceBundleType resourceBundleType) { + this.resourceBundleType = resourceBundleType; + return this; + } + + @NotNull + public Class getTargetType() { + return targetType; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) + return false; + I18nPropertiesRequest that = (I18nPropertiesRequest) o; + return language == that.language && resourceBundleType == that.resourceBundleType && Objects.equals(targetType, that.targetType); + } + + @Override + public int hashCode() { + return Objects.hash(language, resourceBundleType, targetType); + } + + public I18nPropertiesRequest setTargetType(@NotNull Class targetType) { + this.targetType = targetType; + return this; + } + + @Override + public String toString() { + return "I18nPropertiesRequest{" + "language=" + language + ", resourceBundleType=" + resourceBundleType + ", prefix='" + targetType + '\'' + + '}'; + } + + public enum ResourceBundleType { + CAPTION, + DESCRIPTION, + ENUMS, + VALIDATION, + STRING, + COUNTRY, + CONTINENT, + SUBCONTINENT + } +} diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/i18n/Strings.java b/sormas-api/src/main/java/de/symeda/sormas/api/i18n/Strings.java index 8e4daf69149..737a1fd6fac 100644 --- a/sormas-api/src/main/java/de/symeda/sormas/api/i18n/Strings.java +++ b/sormas-api/src/main/java/de/symeda/sormas/api/i18n/Strings.java @@ -901,6 +901,9 @@ public interface Strings { String headingStoppedFollowUp = "headingStoppedFollowUp"; String headingSurveillanceReports = "headingSurveillanceReports"; String headingSurveyGenerateDocument = "headingSurveyGenerateDocument"; + String headingSurveyResponseCorrectAndReprocess = "headingSurveyResponseCorrectAndReprocess"; + String headingSurveyResponseDetails = "headingSurveyResponseDetails"; + String headingSurveyResponseFailures = "headingSurveyResponseFailures"; String headingSurveySendDocument = "headingSurveySendDocument"; String headingSurveySideComponent = "headingSurveySideComponent"; String headingSymptomJournalAccountCreation = "headingSymptomJournalAccountCreation"; @@ -1649,6 +1652,9 @@ public interface Strings { String messageSurveyNoDocumentTemplate = "messageSurveyNoDocumentTemplate"; String messageSurveyNoEmailTemplate = "messageSurveyNoEmailTemplate"; String messageSurveyNoTokens = "messageSurveyNoTokens"; + String messageSurveyResponseAllFieldsApplied = "messageSurveyResponseAllFieldsApplied"; + String messageSurveyResponseNotYetProcessed = "messageSurveyResponseNotYetProcessed"; + String messageSurveyResponseReprocessed = "messageSurveyResponseReprocessed"; String messageSurveySaved = "messageSurveySaved"; String messageSurveyTokenDelete = "messageSurveyTokenDelete"; String messageSurveyTokenSaved = "messageSurveyTokenSaved"; diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/patch/CaseDataPatchRequest.java b/sormas-api/src/main/java/de/symeda/sormas/api/patch/CaseDataPatchRequest.java new file mode 100644 index 00000000000..e704c9c585e --- /dev/null +++ b/sormas-api/src/main/java/de/symeda/sormas/api/patch/CaseDataPatchRequest.java @@ -0,0 +1,162 @@ +package de.symeda.sormas.api.patch; + +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import javax.annotation.Nullable; +import javax.validation.constraints.NotNull; + +import de.symeda.sormas.api.Language; +import de.symeda.sormas.api.info.InfoFacade; + +/** + * Specifies how the patch operation must be performed. + */ +public class CaseDataPatchRequest { + + @NotNull + private String caseUuid; + + private boolean patchedInCaseOfFailures = false; + + @NotNull + private DataReplacementStrategy replacementStrategy = DataReplacementStrategy.IF_NOT_ALREADY_PRESENT; + + @NotNull + private EmptyValueBehavior emptyValueBehavior = EmptyValueBehavior.IGNORE; + + /** + * Key are those from with root being the {@link de.symeda.sormas.api.caze.CaseDataDto}. + * The accepted fields are those from {@link InfoFacade#generateDataDictionary()}. + */ + @NotNull + private Map patchDictionary; + + /** + * Origin that wants the patch operation. + * Can be used within {@link de.symeda.sormas.api.patch.mapping.FieldCustomMapper}. + */ + @Nullable + private String origin; + + /** + * To be able to support I18n inputs the input languages can be passed, system locale by default. + */ + @Nullable + private List inputLanguages; + + /** + * If true, for enumeration-like targetTypes the default value will be used. + * Mostly "OTHER". + */ + private boolean allowFallbackValues = true; + + public String getCaseUuid() { + return caseUuid; + } + + public CaseDataPatchRequest setCaseUuid(String caseUuid) { + this.caseUuid = caseUuid; + return this; + } + + public DataReplacementStrategy getReplacementStrategy() { + return replacementStrategy; + } + + public CaseDataPatchRequest setReplacementStrategy(DataReplacementStrategy replacementStrategy) { + this.replacementStrategy = replacementStrategy; + return this; + } + + public Map getPatchDictionary() { + return patchDictionary; + } + + public CaseDataPatchRequest setPatchDictionary(Map patchDictionary) { + this.patchDictionary = patchDictionary; + return this; + } + + public EmptyValueBehavior getEmptyValueBehavior() { + return emptyValueBehavior; + } + + public CaseDataPatchRequest setEmptyValueBehavior(EmptyValueBehavior emptyValueBehavior) { + this.emptyValueBehavior = emptyValueBehavior; + return this; + } + + @Nullable + public String getOrigin() { + return origin; + } + + public CaseDataPatchRequest setOrigin(@Nullable String origin) { + this.origin = origin; + return this; + } + + public List getInputLanguages() { + return inputLanguages; + } + + public CaseDataPatchRequest setInputLanguages(List inputLanguages) { + this.inputLanguages = inputLanguages; + return this; + } + + public boolean isAllowFallbackValues() { + return allowFallbackValues; + } + + public CaseDataPatchRequest setAllowFallbackValues(boolean allowFallbackValues) { + this.allowFallbackValues = allowFallbackValues; + return this; + } + + public boolean isPatchedInCaseOfFailures() { + return patchedInCaseOfFailures; + } + + public CaseDataPatchRequest setPatchedInCaseOfFailures(boolean patchedInCaseOfFailures) { + this.patchedInCaseOfFailures = patchedInCaseOfFailures; + return this; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) + return false; + CaseDataPatchRequest that = (CaseDataPatchRequest) o; + return patchedInCaseOfFailures == that.patchedInCaseOfFailures + && allowFallbackValues == that.allowFallbackValues + && Objects.equals(caseUuid, that.caseUuid) + && replacementStrategy == that.replacementStrategy + && emptyValueBehavior == that.emptyValueBehavior + && Objects.equals(patchDictionary, that.patchDictionary) + && Objects.equals(origin, that.origin) + && Objects.equals(inputLanguages, that.inputLanguages); + } + + @Override + public int hashCode() { + return Objects.hash( + caseUuid, + patchedInCaseOfFailures, + replacementStrategy, + emptyValueBehavior, + patchDictionary, + origin, + inputLanguages, + allowFallbackValues); + } + + @Override + public String toString() { + return "CaseDataPatchRequest{" + "caseUuid='" + caseUuid + '\'' + ", patchedInCaseOfFailures=" + patchedInCaseOfFailures + + ", replacementStrategy=" + replacementStrategy + ", emptyValueBehavior=" + emptyValueBehavior + ", patchDictionary=" + patchDictionary + + ", origin='" + origin + '\'' + ", inputLanguages=" + inputLanguages + ", allowFallbackValues=" + allowFallbackValues + '}'; + } +} diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/patch/DataPatchFailure.java b/sormas-api/src/main/java/de/symeda/sormas/api/patch/DataPatchFailure.java new file mode 100644 index 00000000000..47f8a9092e0 --- /dev/null +++ b/sormas-api/src/main/java/de/symeda/sormas/api/patch/DataPatchFailure.java @@ -0,0 +1,72 @@ +package de.symeda.sormas.api.patch; + +import java.io.Serializable; +import java.util.Objects; + +import javax.annotation.Nullable; +import javax.validation.constraints.NotNull; + +/** + * Resulting object that is built in case a single field couldn't be mapped during data patching. + */ +public class DataPatchFailure implements Serializable { + + private static final long serialVersionUID = 1L; + + @NotNull + private DataPatchFailureCause dataPatchFailureCause; + + @Nullable + private Object existingFieldValue; + + @Nullable + private Object providedFieldValue; + + public DataPatchFailureCause getDataPatchFailureCause() { + return dataPatchFailureCause; + } + + public DataPatchFailure setDataPatchFailureCause(DataPatchFailureCause dataPatchFailureCause) { + this.dataPatchFailureCause = dataPatchFailureCause; + return this; + } + + public Object getExistingFieldValue() { + return existingFieldValue; + } + + public DataPatchFailure setExistingFieldValue(Object existingFieldValue) { + this.existingFieldValue = existingFieldValue; + return this; + } + + public Object getProvidedFieldValue() { + return providedFieldValue; + } + + public DataPatchFailure setProvidedFieldValue(Object providedFieldValue) { + this.providedFieldValue = providedFieldValue; + return this; + } + + @Override + public String toString() { + return "DataPatchFailure{" + "dataPatchFailureCause=" + dataPatchFailureCause + ", existingFieldValue=" + existingFieldValue + + ", providedFieldValue=" + providedFieldValue + "\"" + '\'' + '}'; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) + return false; + DataPatchFailure that = (DataPatchFailure) o; + return dataPatchFailureCause == that.dataPatchFailureCause + && Objects.equals(existingFieldValue, that.existingFieldValue) + && Objects.equals(providedFieldValue, that.providedFieldValue); + } + + @Override + public int hashCode() { + return Objects.hash(dataPatchFailureCause, existingFieldValue, providedFieldValue); + } +} diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/patch/DataPatchFailureCause.java b/sormas-api/src/main/java/de/symeda/sormas/api/patch/DataPatchFailureCause.java new file mode 100644 index 00000000000..3ae1652ef4f --- /dev/null +++ b/sormas-api/src/main/java/de/symeda/sormas/api/patch/DataPatchFailureCause.java @@ -0,0 +1,83 @@ +package de.symeda.sormas.api.patch; + +/** + * Reason a specific field couldn't be patched. + */ +public enum DataPatchFailureCause { + + /** + * Occurs if input tries to use multiple fields approach: CaseData.(symptoms.onsetDate|hospitalization.admissionDate). + */ + INVALID_MULTIPLE_FIELDS_FORMAT, + + /** + * Some fields require a particular handling and are handled as group, to trigger the handling some fields are mandatory. + */ + MISSING_MANDATORY_FIELD_FOR_GROUP, + + /** + * Alias cannot be mapped to a single physical path. + * Path aliases base on the "Field ID" field from the generated data dictionary can be used to shorten the physical path. + */ + FORBIDDEN_NON_UNIQUE_ALIAS, + + /** + * Some fields have only a specific list of allowed values, if not present and no fallback then fails. Examples: + * - {@link de.symeda.sormas.api.customizableenum.CustomizableEnum} + * - {@link Enum} + * - {@link de.symeda.sormas.api.ReferenceDto} + */ + NOT_PRESENT_IN_REFERENCE_DATA_LIST, + + /** + * Occurs the field is not supported by the disease / country / feature. + * Error message must be somewhat generic to specify the Data Dictionary should be checked. + */ + UNSUPPORTED_FIELD_FOR_DISEASE_OR_COUNTRY_OR_FEATURE, + + /** + * Path does not start with the allowed prefixes: example: CaseData or Person. + */ + UNSUPPORTED_PREFIX, + + /** + * Invalid field name was provided that cannot be matched with an existing field. + */ + FIELD_DOES_NOT_EXIST, + + /** + * Some fields are not meant to be patched: per example technical fields like UUID. + */ + FORBIDDEN_FIELD, + + /** + * Can occur if following patch config was set: {@link DataReplacementStrategy#IF_NOT_ALREADY_PRESENT}. + * Occurs only if the value is different of the current. No error if value stays the same. + */ + FORBIDDEN_VALUE_OVERRIDE, + + /** + * Example: Expected number but got "a". + */ + INVALID_VALUE_TYPE, + + /** + * A mapper is missing and must be implemented. + */ + UNSUPPORTED_TARGET_TYPE, + + INVALID_PATH_FORMAT, + + /** + * The path contains the {@code _duplicate_} marker, meaning the key appeared more than once in the input and this is the duplicate + * occurrence. + * Duplicate entries cannot be processed because their intended target is ambiguous. + */ + DUPLICATE_FIELD, + + /** + * This means there is a hole in the implementation. + */ + TECHNICAL + +} diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/patch/DataPatchResponse.java b/sormas-api/src/main/java/de/symeda/sormas/api/patch/DataPatchResponse.java new file mode 100644 index 00000000000..a335af8d96e --- /dev/null +++ b/sormas-api/src/main/java/de/symeda/sormas/api/patch/DataPatchResponse.java @@ -0,0 +1,89 @@ +package de.symeda.sormas.api.patch; + +import java.io.Serializable; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +import org.apache.commons.collections4.MapUtils; + +/** + * Response to a patch request. + */ +public class DataPatchResponse implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * True if the dictionary was applied to the specified fields on the given entities. + *

+ * Will be false in case of {@link CaseDataPatchRequest#isPatchedInCaseOfFailures()} is false and response contains + * {@link DataPatchResponse#failures} + */ + private boolean applied = true; + + /** + * Actual patched values for the specified keys. + * Will NOT contain fields that were NOT patched (even though passed in original patchDictionary). + */ + private Map validPatchDictionary = new HashMap<>(); + + /** + * Provides the reason for the failure for the impacted fields. + */ + private Map failures = new HashMap<>(); + + public Map getValidPatchDictionary() { + return validPatchDictionary; + } + + public DataPatchResponse setValidPatchDictionary(Map validPatchDictionary) { + this.validPatchDictionary = validPatchDictionary; + return this; + } + + public Map getFailures() { + return failures; + } + + public DataPatchResponse setFailures(Map failures) { + this.failures = failures; + return this; + } + + public boolean isApplied() { + return applied; + } + + public DataPatchResponse setApplied(boolean applied) { + this.applied = applied; + return this; + } + + /** + * Patch are atomic operations: either fully or not at all. + * + * @return true if operation was NOT applied, else false. + */ + public boolean hasFailures() { + return MapUtils.isNotEmpty(failures); + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) + return false; + DataPatchResponse that = (DataPatchResponse) o; + return applied == that.applied && Objects.equals(validPatchDictionary, that.validPatchDictionary) && Objects.equals(failures, that.failures); + } + + @Override + public int hashCode() { + return Objects.hash(applied, validPatchDictionary, failures); + } + + @Override + public String toString() { + return "DataPatchResponse{" + "applied=" + applied + ", validPatchDictionary=" + validPatchDictionary + ", failures=" + failures + '}'; + } +} diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/patch/DataPatcher.java b/sormas-api/src/main/java/de/symeda/sormas/api/patch/DataPatcher.java new file mode 100644 index 00000000000..6d8f0d2a146 --- /dev/null +++ b/sormas-api/src/main/java/de/symeda/sormas/api/patch/DataPatcher.java @@ -0,0 +1,16 @@ +package de.symeda.sormas.api.patch; + +/** + * Allows to partially patch data from a case. + */ +public interface DataPatcher { + + /** + * Allow patching data for a specific case. + * + * @param request + * instructions for the data patch + * @return response that indicates + */ + DataPatchResponse patch(CaseDataPatchRequest request); +} diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/patch/DataReplacementStrategy.java b/sormas-api/src/main/java/de/symeda/sormas/api/patch/DataReplacementStrategy.java new file mode 100644 index 00000000000..fd77175710f --- /dev/null +++ b/sormas-api/src/main/java/de/symeda/sormas/api/patch/DataReplacementStrategy.java @@ -0,0 +1,16 @@ +package de.symeda.sormas.api.patch; + +/** + * Specifies how during a patch operation the override of existing data must be handled. + */ +public enum DataReplacementStrategy { + /** + * No matter what the current value is, it will be replaced with the provided value. + */ + ALWAYS, + + /** + * New value will not be applied if there already is a value for the specified field. + */ + IF_NOT_ALREADY_PRESENT +} diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/patch/EmptyValueBehavior.java b/sormas-api/src/main/java/de/symeda/sormas/api/patch/EmptyValueBehavior.java new file mode 100644 index 00000000000..7369de2ae1f --- /dev/null +++ b/sormas-api/src/main/java/de/symeda/sormas/api/patch/EmptyValueBehavior.java @@ -0,0 +1,9 @@ +package de.symeda.sormas.api.patch; + +/** + * Defines how Empty values: null or "" (empty string) should be taken into account during patch operation. + */ +public enum EmptyValueBehavior { + IGNORE, + REPLACE +} diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/patch/SinglePatchResult.java b/sormas-api/src/main/java/de/symeda/sormas/api/patch/SinglePatchResult.java new file mode 100644 index 00000000000..746d85527ee --- /dev/null +++ b/sormas-api/src/main/java/de/symeda/sormas/api/patch/SinglePatchResult.java @@ -0,0 +1,45 @@ +package de.symeda.sormas.api.patch; + +import javax.annotation.Nullable; +import javax.validation.constraints.NotNull; + +public class SinglePatchResult { + + @NotNull + private String fieldName; + + @Nullable + private Object value; + + @Nullable + private DataPatchFailure failure; + + public String getFieldName() { + return fieldName; + } + + public SinglePatchResult setFieldName(String fieldName) { + this.fieldName = fieldName; + return this; + } + + @Nullable + public Object getValue() { + return value; + } + + public SinglePatchResult setValue(@Nullable Object value) { + this.value = value; + return this; + } + + @Nullable + public DataPatchFailure getFailure() { + return failure; + } + + public SinglePatchResult setFailure(@Nullable DataPatchFailure failure) { + this.failure = failure; + return this; + } +} diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/patch/mapping/FieldCustomMapper.java b/sormas-api/src/main/java/de/symeda/sormas/api/patch/mapping/FieldCustomMapper.java new file mode 100644 index 00000000000..40b4d14db3f --- /dev/null +++ b/sormas-api/src/main/java/de/symeda/sormas/api/patch/mapping/FieldCustomMapper.java @@ -0,0 +1,36 @@ +package de.symeda.sormas.api.patch.mapping; + +import java.util.Optional; +import java.util.Set; + +import de.symeda.sormas.api.Disease; +import de.symeda.sormas.api.patch.DataPatchFailure; + +/** + * Allows to patch a single SORMAS fields in a specific manner, because default type mapping doesn't fit. + * Example: a field is displayed a single field in UI but is stored as multiple values in DTO / entities. + */ +public interface FieldCustomMapper { + + /* + * In case of failure returns the triggered failure otherwise successfully patch the value on the specific object. + */ + Optional map(FieldPatchRequest request); + + /** + * Warn each field must be unique among all {@link FieldCustomMapper} implementations. + * + * @return fields supported by this specific mapper. + */ + Set supportedFields(); + + /** + * Some fields are specific to some diseases. + * + * @return set of supported diseases. + */ + default Set supportedDisease() { + return Disease.ALL_DISEASES; + } + +} diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/patch/mapping/FieldPatchRequest.java b/sormas-api/src/main/java/de/symeda/sormas/api/patch/mapping/FieldPatchRequest.java new file mode 100644 index 00000000000..35509a1df2d --- /dev/null +++ b/sormas-api/src/main/java/de/symeda/sormas/api/patch/mapping/FieldPatchRequest.java @@ -0,0 +1,98 @@ +package de.symeda.sormas.api.patch.mapping; + +import java.util.Objects; + +import javax.annotation.Nullable; +import javax.validation.constraints.NotNull; + +import de.symeda.sormas.api.patch.DataReplacementStrategy; + +/** + * Patching request for a specific field, target type is known by the {@link FieldCustomMapper}. + */ +public final class FieldPatchRequest { + + @NotNull + private String fieldName; + + @NotNull + private Object target; + + @NotNull + private Object value; + + @NotNull + private DataReplacementStrategy replacementType; + + @Nullable + private String origin; + + public String getFieldName() { + return fieldName; + } + + public FieldPatchRequest setFieldName(String fieldName) { + this.fieldName = fieldName; + return this; + } + + public Object getTarget() { + return target; + } + + public FieldPatchRequest setTarget(Object target) { + this.target = target; + return this; + } + + public Object getValue() { + return value; + } + + public FieldPatchRequest setValue(Object value) { + this.value = value; + return this; + } + + public DataReplacementStrategy getReplacementType() { + return replacementType; + } + + public FieldPatchRequest setReplacementType(DataReplacementStrategy replacementType) { + this.replacementType = replacementType; + return this; + } + + @Nullable + public String getOrigin() { + return origin; + } + + public FieldPatchRequest setOrigin(@Nullable String origin) { + this.origin = origin; + return this; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) + return false; + FieldPatchRequest that = (FieldPatchRequest) o; + return Objects.equals(fieldName, that.fieldName) + && Objects.equals(target, that.target) + && Objects.equals(value, that.value) + && replacementType == that.replacementType + && Objects.equals(origin, that.origin); + } + + @Override + public int hashCode() { + return Objects.hash(fieldName, target, value, replacementType, origin); + } + + @Override + public String toString() { + return "FieldPatchRequest{" + "fieldName='" + fieldName + '\'' + ", target=" + target + ", value=" + value + ", replacementType=" + + replacementType + ", origin='" + origin + '\'' + '}'; + } +} diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/patch/mapping/ValueMapperDefault.java b/sormas-api/src/main/java/de/symeda/sormas/api/patch/mapping/ValueMapperDefault.java new file mode 100644 index 00000000000..d60d0f58123 --- /dev/null +++ b/sormas-api/src/main/java/de/symeda/sormas/api/patch/mapping/ValueMapperDefault.java @@ -0,0 +1,18 @@ +package de.symeda.sormas.api.patch.mapping; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks an enum constant as the default fallback value for {@link ValuePatchMapper} for enums. + * Takes precedence over the conventional "OTHER" fallback. + */ +@Target(ElementType.FIELD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface ValueMapperDefault { + +} diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/patch/mapping/ValueMappingResult.java b/sormas-api/src/main/java/de/symeda/sormas/api/patch/mapping/ValueMappingResult.java new file mode 100644 index 00000000000..84318d1a2cc --- /dev/null +++ b/sormas-api/src/main/java/de/symeda/sormas/api/patch/mapping/ValueMappingResult.java @@ -0,0 +1,64 @@ +package de.symeda.sormas.api.patch.mapping; + +import java.util.Objects; + +import de.symeda.sormas.api.patch.DataPatchFailureCause; + +/** + * Result of attempting to patch a value into some target. + * + * @param + */ +public class ValueMappingResult { + + private T data; + private DataPatchFailureCause dataPatchFailureCause; + + public static ValueMappingResult withData(T data) { + ValueMappingResult result = new ValueMappingResult<>(); + result.setData(data); + return result; + } + + public static ValueMappingResult withCause(DataPatchFailureCause dataPatchFailureCause) { + ValueMappingResult result = new ValueMappingResult<>(); + result.setDataPatchFailureCause(dataPatchFailureCause); + return result; + } + + public T getData() { + return data; + } + + public ValueMappingResult setData(T data) { + this.data = data; + return this; + } + + public DataPatchFailureCause getDataPatchFailureCause() { + return dataPatchFailureCause; + } + + public ValueMappingResult setDataPatchFailureCause(DataPatchFailureCause dataPatchFailureCause) { + this.dataPatchFailureCause = dataPatchFailureCause; + return this; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) + return false; + ValueMappingResult that = (ValueMappingResult) o; + return Objects.equals(data, that.data) && dataPatchFailureCause == that.dataPatchFailureCause; + } + + @Override + public int hashCode() { + return Objects.hash(data, dataPatchFailureCause); + } + + @Override + public String toString() { + return "ValueMappingResult{" + "data=" + data + ", dataPatchFailureCause=" + dataPatchFailureCause + '}'; + } +} diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/patch/mapping/ValuePatchMapper.java b/sormas-api/src/main/java/de/symeda/sormas/api/patch/mapping/ValuePatchMapper.java new file mode 100644 index 00000000000..baf0dc157cf --- /dev/null +++ b/sormas-api/src/main/java/de/symeda/sormas/api/patch/mapping/ValuePatchMapper.java @@ -0,0 +1,42 @@ +package de.symeda.sormas.api.patch.mapping; + +import javax.validation.constraints.NotNull; + +import de.symeda.sormas.api.utils.OrderedRegisterable; + +/** + * Contract to specify how a type must be mapped into a value, NOT field specific. + */ +public interface ValuePatchMapper extends OrderedRegisterable { + + /** + * + * @param value + * raw value type, must either by String or the actual type. + * @param targetType + * type that is expected. + * @return actual value + * @param + * target type + * @throws RuntimeException + * in case of the value couldn't be mapped. + */ + @NotNull + default ValueMappingResult map(Object value, @NotNull Class targetType) { + return this.map(new ValuePatchRequest().setValue(value).setTargetType(targetType)); + } + + /** + * + * @param request + * to specif how the value should be mapped. + * @return actual value + * @param + * target type + * @throws RuntimeException + * in case of the value couldn't be mapped. + */ + @NotNull + ValueMappingResult map(ValuePatchRequest request); + +} diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/patch/mapping/ValuePatchRequest.java b/sormas-api/src/main/java/de/symeda/sormas/api/patch/mapping/ValuePatchRequest.java new file mode 100644 index 00000000000..db670ece62e --- /dev/null +++ b/sormas-api/src/main/java/de/symeda/sormas/api/patch/mapping/ValuePatchRequest.java @@ -0,0 +1,91 @@ +package de.symeda.sormas.api.patch.mapping; + +import java.util.List; +import java.util.Objects; + +import javax.annotation.Nullable; +import javax.validation.constraints.NotNull; + +import de.symeda.sormas.api.Language; + +/** + * Specifies how to patch a single value into a targetType. + */ +public class ValuePatchRequest { + + @NotNull + private Object value; + + @NotNull + private Class targetType; + + /** + * To be able to support I18n inputs the input languages can be passed, system locale by default. + */ + @Nullable + private List inputLanguages; + + /** + * If true, for enumeration-like targetTypes the default value will be used. + * Mostly "OTHER". + */ + private boolean allowFallbackValues = true; + + public Object getValue() { + return value; + } + + public ValuePatchRequest setValue(Object value) { + this.value = value; + return this; + } + + public Class getTargetType() { + return targetType; + } + + public ValuePatchRequest setTargetType(Class targetType) { + this.targetType = targetType; + return this; + } + + public List getInputLanguages() { + return inputLanguages; + } + + public ValuePatchRequest setInputLanguages(List inputLanguages) { + this.inputLanguages = inputLanguages; + return this; + } + + public boolean isAllowFallbackValues() { + return allowFallbackValues; + } + + public ValuePatchRequest setAllowFallbackValues(boolean allowDefaultValues) { + this.allowFallbackValues = allowDefaultValues; + return this; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) + return false; + ValuePatchRequest that = (ValuePatchRequest) o; + return allowFallbackValues == that.allowFallbackValues + && Objects.equals(value, that.value) + && Objects.equals(targetType, that.targetType) + && Objects.equals(inputLanguages, that.inputLanguages); + } + + @Override + public int hashCode() { + return Objects.hash(value, targetType, inputLanguages, allowFallbackValues); + } + + @Override + public String toString() { + return "ValuePatchRequest{" + "value=" + value + ", targetType=" + targetType + ", inputLanguages=" + inputLanguages + ", allowDefaultValues=" + + allowFallbackValues + '}'; + } +} diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/patch/partial_retrieval/DisplayableFieldInfo.java b/sormas-api/src/main/java/de/symeda/sormas/api/patch/partial_retrieval/DisplayableFieldInfo.java new file mode 100644 index 00000000000..effc5427d43 --- /dev/null +++ b/sormas-api/src/main/java/de/symeda/sormas/api/patch/partial_retrieval/DisplayableFieldInfo.java @@ -0,0 +1,52 @@ +package de.symeda.sormas.api.patch.partial_retrieval; + +import java.io.Serializable; +import java.util.Objects; + +/** + * Type to display a specific value to the user with its field name and its value. + */ +public class DisplayableFieldInfo implements Serializable { + + private static final long serialVersionUID = 1L; + + private String translatedFieldName; + private String translatedFieldValue; + + public String getTranslatedFieldName() { + return translatedFieldName; + } + + public DisplayableFieldInfo setTranslatedFieldName(String translatedFieldName) { + this.translatedFieldName = translatedFieldName; + return this; + } + + public String getTranslatedFieldValue() { + return translatedFieldValue; + } + + public DisplayableFieldInfo setTranslatedFieldValue(String translatedFieldValue) { + this.translatedFieldValue = translatedFieldValue; + return this; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) + return false; + DisplayableFieldInfo that = (DisplayableFieldInfo) o; + return Objects.equals(translatedFieldName, that.translatedFieldName) && Objects.equals(translatedFieldValue, that.translatedFieldValue); + } + + @Override + public int hashCode() { + return Objects.hash(translatedFieldName, translatedFieldValue); + } + + @Override + public String toString() { + return "DisplayableFieldInfo{" + "translatedFieldName='" + translatedFieldName + '\'' + ", translatedFieldValue='" + translatedFieldValue + + '\'' + '}'; + } +} diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/patch/partial_retrieval/DisplayablePartialRetrievalResponse.java b/sormas-api/src/main/java/de/symeda/sormas/api/patch/partial_retrieval/DisplayablePartialRetrievalResponse.java new file mode 100644 index 00000000000..f0bc8226529 --- /dev/null +++ b/sormas-api/src/main/java/de/symeda/sormas/api/patch/partial_retrieval/DisplayablePartialRetrievalResponse.java @@ -0,0 +1,59 @@ +package de.symeda.sormas.api.patch.partial_retrieval; + +import de.symeda.sormas.api.audit.AuditedClass; + +import java.io.Serializable; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +/** + * In symmetry to the partial patching / partial retrieval is also possible, this represents an attempt to partially retrieve some fields. + *

+ * Implementation is inspired from patching. + */ +@AuditedClass +public class DisplayablePartialRetrievalResponse implements Serializable { + + private static final long serialVersionUID = 1L; + + private Map fieldInfoDictionary = new HashMap<>(); + + private Map failuresDescriptions = new HashMap<>(); + + public Map getFieldInfoDictionary() { + return fieldInfoDictionary; + } + + public DisplayablePartialRetrievalResponse setFieldInfoDictionary(Map fieldInfoDictionary) { + this.fieldInfoDictionary = fieldInfoDictionary; + return this; + } + + public Map getFailuresDescriptions() { + return failuresDescriptions; + } + + public DisplayablePartialRetrievalResponse setFailuresDescriptions(Map failuresDescriptions) { + this.failuresDescriptions = failuresDescriptions; + return this; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) + return false; + DisplayablePartialRetrievalResponse that = (DisplayablePartialRetrievalResponse) o; + return Objects.equals(fieldInfoDictionary, that.fieldInfoDictionary) && Objects.equals(failuresDescriptions, that.failuresDescriptions); + } + + @Override + public int hashCode() { + return Objects.hash(fieldInfoDictionary, failuresDescriptions); + } + + @Override + public String toString() { + return "DisplayablePartialResponse{" + "fieldInfoDictionary=" + fieldInfoDictionary + ", failuresDescriptions=" + failuresDescriptions + '}'; + } +} diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/patch/partial_retrieval/FieldInfo.java b/sormas-api/src/main/java/de/symeda/sormas/api/patch/partial_retrieval/FieldInfo.java new file mode 100644 index 00000000000..d40607f916e --- /dev/null +++ b/sormas-api/src/main/java/de/symeda/sormas/api/patch/partial_retrieval/FieldInfo.java @@ -0,0 +1,60 @@ +package de.symeda.sormas.api.patch.partial_retrieval; + +import java.util.Objects; + +/** + * Half technical and half user-friendly representation of a field. + */ +public class FieldInfo { + + private String translatedFieldName; + private Class fieldType; + private Object fieldValue; + + public String getTranslatedFieldName() { + return translatedFieldName; + } + + public FieldInfo setTranslatedFieldName(String translatedFieldName) { + this.translatedFieldName = translatedFieldName; + return this; + } + + public Class getFieldType() { + return fieldType; + } + + public FieldInfo setFieldType(Class fieldType) { + this.fieldType = fieldType; + return this; + } + + public Object getFieldValue() { + return fieldValue; + } + + public FieldInfo setFieldValue(Object fieldValue) { + this.fieldValue = fieldValue; + return this; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) + return false; + FieldInfo fieldInfo = (FieldInfo) o; + return Objects.equals(translatedFieldName, fieldInfo.translatedFieldName) + && Objects.equals(fieldType, fieldInfo.fieldType) + && Objects.equals(fieldValue, fieldInfo.fieldValue); + } + + @Override + public int hashCode() { + return Objects.hash(translatedFieldName, fieldType, fieldValue); + } + + @Override + public String toString() { + return "FieldInfo{" + "translatedFieldName='" + translatedFieldName + '\'' + ", fieldType=" + fieldType + ", fieldValue=" + fieldValue + '}'; + } +} diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/patch/partial_retrieval/PartialRetrievalFailureCause.java b/sormas-api/src/main/java/de/symeda/sormas/api/patch/partial_retrieval/PartialRetrievalFailureCause.java new file mode 100644 index 00000000000..5b7c7f948da --- /dev/null +++ b/sormas-api/src/main/java/de/symeda/sormas/api/patch/partial_retrieval/PartialRetrievalFailureCause.java @@ -0,0 +1,58 @@ +package de.symeda.sormas.api.patch.partial_retrieval; + +/** + * Reason for partial retrieval of a field to fail. + */ +public enum PartialRetrievalFailureCause { + + /** + * More specific technical error but similar {@link this#TECHNICAL}. + */ + ENTITY_COULD_NOT_BE_FOUND, + + /** + * Occurs if input tries to use multiple fields approach: CaseData.(symptoms.onsetDate|hospitalization.admissionDate). + */ + INVALID_MULTIPLE_FIELDS_FORMAT, + + /** + * Alias cannot be mapped to a single physical path. + * Path aliases base on the "Field ID" field from the generated data dictionary can be used to shorten the physical path. + */ + FORBIDDEN_NON_UNIQUE_ALIAS, + + /** + * Occurs the field is not supported by the disease / country / feature. + * Error message must be somewhat generic to specify the Data Dictionary should be checked. + */ + UNSUPPORTED_FIELD_FOR_DISEASE_OR_COUNTRY_OR_FEATURE, + + /** + * Path does not start with the allowed prefixes: example: CaseData or Person. + */ + UNSUPPORTED_PREFIX, + + /** + * Invalid field name was provided that cannot be matched with an existing field. + */ + FIELD_DOES_NOT_EXIST, + + /** + * Some fields are not meant to be patched: per example technical fields like UUID. + */ + FORBIDDEN_FIELD, + + INVALID_PATH_FORMAT, + + /** + * The path contains the {@code _duplicate_} marker, meaning the key appeared more than once in the input and this is the duplicate + * occurrence. + * Duplicate entries cannot be processed because their intended target is ambiguous. + */ + DUPLICATE_FIELD, + + /** + * This means there is a hole in the implementation. + */ + TECHNICAL +} diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/patch/partial_retrieval/PartialRetrievalRequest.java b/sormas-api/src/main/java/de/symeda/sormas/api/patch/partial_retrieval/PartialRetrievalRequest.java new file mode 100644 index 00000000000..c4fb2b1a639 --- /dev/null +++ b/sormas-api/src/main/java/de/symeda/sormas/api/patch/partial_retrieval/PartialRetrievalRequest.java @@ -0,0 +1,62 @@ +package de.symeda.sormas.api.patch.partial_retrieval; + +import java.util.Objects; +import java.util.Set; + +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; + +/** + * Allows to retrieve field values and their respective field names. + */ +public class PartialRetrievalRequest { + + /** + * Root object for retrieval is the case. + */ + @NotNull + private String caseUuid; + + /** + * Example: CaseData.hospitalization. Must be the physical path of the DTO. + */ + @NotNull + @NotEmpty + private Set fieldsToRetrieve; + + public String getCaseUuid() { + return caseUuid; + } + + public PartialRetrievalRequest setCaseUuid(String caseUuid) { + this.caseUuid = caseUuid; + return this; + } + + public Set getFieldsToRetrieve() { + return fieldsToRetrieve; + } + + public PartialRetrievalRequest setFieldsToRetrieve(Set fieldsToRetrieve) { + this.fieldsToRetrieve = fieldsToRetrieve; + return this; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) + return false; + PartialRetrievalRequest that = (PartialRetrievalRequest) o; + return Objects.equals(caseUuid, that.caseUuid) && Objects.equals(fieldsToRetrieve, that.fieldsToRetrieve); + } + + @Override + public int hashCode() { + return Objects.hash(caseUuid, fieldsToRetrieve); + } + + @Override + public String toString() { + return "PartialRetrievalRequest{" + "caseUuid='" + caseUuid + '\'' + ", fieldsToRetrieve=" + fieldsToRetrieve + '}'; + } +} diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/patch/partial_retrieval/PartialRetrievalResponse.java b/sormas-api/src/main/java/de/symeda/sormas/api/patch/partial_retrieval/PartialRetrievalResponse.java new file mode 100644 index 00000000000..4e16be6dd6b --- /dev/null +++ b/sormas-api/src/main/java/de/symeda/sormas/api/patch/partial_retrieval/PartialRetrievalResponse.java @@ -0,0 +1,47 @@ +package de.symeda.sormas.api.patch.partial_retrieval; + +import java.util.Map; +import java.util.Objects; + +public class PartialRetrievalResponse { + + private Map fieldInfoDictionary; + + private Map failuresDictionary; + + public Map getFieldInfoDictionary() { + return fieldInfoDictionary; + } + + public PartialRetrievalResponse setFieldInfoDictionary(Map fieldInfoDictionary) { + this.fieldInfoDictionary = fieldInfoDictionary; + return this; + } + + public Map getFailuresDictionary() { + return failuresDictionary; + } + + public PartialRetrievalResponse setFailuresDictionary(Map failuresDictionary) { + this.failuresDictionary = failuresDictionary; + return this; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) + return false; + PartialRetrievalResponse that = (PartialRetrievalResponse) o; + return Objects.equals(fieldInfoDictionary, that.fieldInfoDictionary) && Objects.equals(failuresDictionary, that.failuresDictionary); + } + + @Override + public int hashCode() { + return Objects.hash(fieldInfoDictionary, failuresDictionary); + } + + @Override + public String toString() { + return "PartialRetrievalResponse{" + "fieldInfoDictionary=" + fieldInfoDictionary + ", failuresDictionary=" + failuresDictionary + '}'; + } +} diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/patch/partial_retrieval/PartialRetriever.java b/sormas-api/src/main/java/de/symeda/sormas/api/patch/partial_retrieval/PartialRetriever.java new file mode 100644 index 00000000000..d4571e2aff5 --- /dev/null +++ b/sormas-api/src/main/java/de/symeda/sormas/api/patch/partial_retrieval/PartialRetriever.java @@ -0,0 +1,25 @@ +package de.symeda.sormas.api.patch.partial_retrieval; + +/** + * Can be used to retrieve partial data linked to a case. + */ +public interface PartialRetriever { + + /** + * Use this to fetch the actual values in their concrete types. + * + * @param request + * to configure retrieval. + * @return response with actual values in their concrete types and possibly errors. + */ + PartialRetrievalResponse retrievePartial(PartialRetrievalRequest request); + + /** + * Use this to fetch the actual values in displayable string format. + * + * @param request + * to configure retrieval. + * @return response with actual values in displayable string format and possibly translated errors. + */ + DisplayablePartialRetrievalResponse retrievePartialForDisplay(PartialRetrievalRequest request); +} diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/person/BirthWeightCategory.java b/sormas-api/src/main/java/de/symeda/sormas/api/person/BirthWeightCategory.java index 76754ae6625..09b779ca7e8 100644 --- a/sormas-api/src/main/java/de/symeda/sormas/api/person/BirthWeightCategory.java +++ b/sormas-api/src/main/java/de/symeda/sormas/api/person/BirthWeightCategory.java @@ -23,18 +23,18 @@ */ public enum BirthWeightCategory { - /** - * Normal birth weight - */ - NORMAL, + /** + * Normal birth weight + */ + NORMAL, - /** - * Low birth weight (stunted growth) - */ - LOW_BIRTH_WEIGHT; + /** + * Low birth weight (stunted growth) + */ + LOW_BIRTH_WEIGHT; - @Override - public String toString() { - return I18nProperties.getEnumCaption(this); - } + @Override + public String toString() { + return I18nProperties.getEnumCaption(this); + } } diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/person/CauseOfDeath.java b/sormas-api/src/main/java/de/symeda/sormas/api/person/CauseOfDeath.java index 642943ae3e1..038574d8ceb 100644 --- a/sormas-api/src/main/java/de/symeda/sormas/api/person/CauseOfDeath.java +++ b/sormas-api/src/main/java/de/symeda/sormas/api/person/CauseOfDeath.java @@ -18,10 +18,13 @@ package de.symeda.sormas.api.person; import de.symeda.sormas.api.i18n.I18nProperties; +import de.symeda.sormas.api.patch.mapping.ValueMapperDefault; public enum CauseOfDeath { EPIDEMIC_DISEASE, + + @ValueMapperDefault OTHER_CAUSE; @Override diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/referencedata/ReferenceDataValueInstanceProvider.java b/sormas-api/src/main/java/de/symeda/sormas/api/referencedata/ReferenceDataValueInstanceProvider.java new file mode 100644 index 00000000000..08cd9bdff02 --- /dev/null +++ b/sormas-api/src/main/java/de/symeda/sormas/api/referencedata/ReferenceDataValueInstanceProvider.java @@ -0,0 +1,37 @@ +package de.symeda.sormas.api.referencedata; + +import java.util.List; +import java.util.Optional; + +import javax.ejb.Remote; +import javax.validation.constraints.NotNull; + +import de.symeda.sormas.api.ReferenceDto; + +/** + * This provider can be used to help find appropriate Reference types when only the type is known. + */ +@Remote +public interface ReferenceDataValueInstanceProvider { + + /** + * + * @param referenceType + * class to fetch. + * @return + * @param + */ + List getAll(@NotNull Class referenceType); + + /** + * + * @param caption + * used candidate to find the adequate value. + * @param referenceType + * actual referenceDto class + * @return optional reference DTO. + * @param + * exact {@link ReferenceDto} type. + */ + Optional getOne(@NotNull String caption, @NotNull Class referenceType); +} diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/survey/SurveyDto.java b/sormas-api/src/main/java/de/symeda/sormas/api/survey/SurveyDto.java index fa61b595833..d7b71a818d3 100644 --- a/sormas-api/src/main/java/de/symeda/sormas/api/survey/SurveyDto.java +++ b/sormas-api/src/main/java/de/symeda/sormas/api/survey/SurveyDto.java @@ -40,6 +40,8 @@ public class SurveyDto extends EntityDto { public static final String NAME = "name"; + public static final String EXTERNAL_ID = "externalId"; + @NotBlank(message = Validations.requiredField) @Size(max = FieldConstraints.CHARACTER_LIMIT_SMALL, message = Validations.textTooLong) private String name; @@ -47,6 +49,7 @@ public class SurveyDto extends EntityDto { private Disease disease; private DocumentTemplateReferenceDto documentTemplate; private DocumentTemplateReferenceDto emailTemplate; + private String externalId; public static SurveyDto build() { SurveyDto survey = new SurveyDto(); @@ -90,4 +93,12 @@ public void setEmailTemplate(DocumentTemplateReferenceDto emailTemplate) { public SurveyReferenceDto toReference() { return new SurveyReferenceDto(getUuid(), getName()); } + + public String getExternalId() { + return externalId; + } + + public void setExternalId(String externalId) { + this.externalId = externalId; + } } diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/survey/SurveyFacade.java b/sormas-api/src/main/java/de/symeda/sormas/api/survey/SurveyFacade.java index e0e0d94ad63..2ea14c3575c 100644 --- a/sormas-api/src/main/java/de/symeda/sormas/api/survey/SurveyFacade.java +++ b/sormas-api/src/main/java/de/symeda/sormas/api/survey/SurveyFacade.java @@ -17,11 +17,14 @@ import java.io.IOException; import java.util.List; +import java.util.Optional; import javax.ejb.Remote; import javax.validation.Valid; import javax.validation.constraints.NotNull; +import org.apache.commons.collections4.CollectionUtils; + import de.symeda.sormas.api.Disease; import de.symeda.sormas.api.docgeneneration.DocumentTemplateDto; import de.symeda.sormas.api.docgeneneration.DocumentTemplateException; @@ -34,37 +37,46 @@ @Remote public interface SurveyFacade { - SurveyDto save(@Valid SurveyDto dto); + SurveyDto save(@Valid SurveyDto dto); void uploadDocumentTemplate(@NotNull SurveyReferenceDto surveyRef, DocumentTemplateDto uploadedDocumentTemplate, byte[] fileContent) throws DocumentTemplateException; SurveyDto getByUuid(String uuid); - long count(SurveyCriteria criteria); + List getByExternalIds(List externalIds); + + default Optional getByExternalId(@NotNull String externalId) { + return Optional.ofNullable(getByExternalIds(List.of(externalId))).filter(CollectionUtils::isNotEmpty).map(surveys -> surveys.get(0)); + } - List getIndexList(SurveyCriteria criteria, Integer first, Integer max, List sortProperties); + long count(SurveyCriteria criteria); - void deletePermanent(String uuid); + List getIndexList(SurveyCriteria criteria, Integer first, Integer max, List sortProperties); - boolean exists(String uuid); + void deletePermanent(String uuid); - SurveyReferenceDto getReferenceByUuid(String uuid); + boolean exists(String uuid); + + SurveyReferenceDto getReferenceByUuid(String uuid); Boolean isEditAllowed(String uuid); void uploadEmailTemplate(@NotNull SurveyReferenceDto surveyReference, DocumentTemplateDto uploadedDocumentTemplateDto, byte[] fileContent) throws DocumentTemplateException; - List getAllByDisease(Disease disease); + List getAllByDisease(Disease disease); + + void generateDocument(SurveyDocumentOptionsDto surveyOptions) throws DocumentTemplateException, ValidationException; - void generateDocument(SurveyDocumentOptionsDto surveyOptions) throws DocumentTemplateException, ValidationException; + void sendDocument(SurveyDocumentOptionsDto surveyOptions) + throws DocumentTemplateException, ValidationException, AttachmentException, IOException, ExternalEmailException; - void sendDocument(SurveyDocumentOptionsDto surveyOptions) throws DocumentTemplateException, ValidationException, AttachmentException, IOException, ExternalEmailException; + boolean hasUnassignedTokens(SurveyReferenceDto survey); - boolean hasUnassignedTokens(SurveyReferenceDto survey); + DocumentVariables getDocumentVariables(SurveyReferenceDto surveyRef) throws DocumentTemplateException; - DocumentVariables getDocumentVariables(SurveyReferenceDto surveyRef) throws DocumentTemplateException; + List getAllAsReference(); - List getAllAsReference(); + List getAllWithExternalSurveyId(); } diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/survey/SurveyTokenDto.java b/sormas-api/src/main/java/de/symeda/sormas/api/survey/SurveyTokenDto.java index d67306abec1..6426a435682 100644 --- a/sormas-api/src/main/java/de/symeda/sormas/api/survey/SurveyTokenDto.java +++ b/sormas-api/src/main/java/de/symeda/sormas/api/survey/SurveyTokenDto.java @@ -44,6 +44,7 @@ public class SurveyTokenDto extends EntityDto { public static final String GENERATED_DOCUMENT = "generatedDocument"; public static final String RESPONSE_RECEIVED = "responseReceived"; public static final String RESPONSE_RECEIVED_DATE = "responseReceivedDate"; + public static final String EXTERNAL_RESPONDENT_ID = "externalRespondentId"; @NotNull(message = Validations.requiredField) private SurveyReferenceDto survey; @@ -57,6 +58,7 @@ public class SurveyTokenDto extends EntityDto { private DocumentReferenceDto generatedDocument; private boolean responseReceived; private Date responseReceivedDate; + private String externalRespondentId; public static SurveyTokenDto build() { SurveyTokenDto token = new SurveyTokenDto(); @@ -140,4 +142,12 @@ public void setResponseReceivedDate(Date responseReceivedDate) { public SurveyTokenReferenceDto toReference() { return new SurveyTokenReferenceDto(getUuid()); } + + public String getExternalRespondentId() { + return externalRespondentId; + } + + public void setExternalRespondentId(String externalRespondentId) { + this.externalRespondentId = externalRespondentId; + } } diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/survey/SurveyTokenFacade.java b/sormas-api/src/main/java/de/symeda/sormas/api/survey/SurveyTokenFacade.java index 78120538d2d..32d57f94b6a 100644 --- a/sormas-api/src/main/java/de/symeda/sormas/api/survey/SurveyTokenFacade.java +++ b/sormas-api/src/main/java/de/symeda/sormas/api/survey/SurveyTokenFacade.java @@ -20,7 +20,9 @@ import javax.ejb.Remote; import javax.validation.Valid; +import de.symeda.sormas.api.survey.external.views.ExternalSurveyView; import de.symeda.sormas.api.utils.SortProperty; +import de.symeda.sormas.api.utils.Tuple; @Remote public interface SurveyTokenFacade { @@ -45,7 +47,17 @@ public interface SurveyTokenFacade { SurveyTokenDto getBySurveyAndToken(SurveyReferenceDto survey, String token); + SurveyTokenDto getBySurveyExternalIdAndToken(String externalSurveyId, String token); + + List getBySurveyReferenceTokenTuples(List> surveyReferenceTokenTuples); + boolean exists(String uuid); SurveyTokenReferenceDto getReferenceByUuid(String uuid); + + /** + * Fetches the questionnaire view for a survey token from the external survey provider. + * Returns null if the provider is unavailable or the token has no external respondent ID. + */ + ExternalSurveyView getExternalSurveyView(String surveyTokenUuid); } diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/survey/SurveyTokenIndexDto.java b/sormas-api/src/main/java/de/symeda/sormas/api/survey/SurveyTokenIndexDto.java index f179803a7f0..f21d7b622b3 100644 --- a/sormas-api/src/main/java/de/symeda/sormas/api/survey/SurveyTokenIndexDto.java +++ b/sormas-api/src/main/java/de/symeda/sormas/api/survey/SurveyTokenIndexDto.java @@ -23,7 +23,6 @@ public class SurveyTokenIndexDto extends PseudonymizableIndexDto { public static final String I18N_PREFIX = "SurveyToken"; private static final long serialVersionUID = 4358173798026207265L; - public static final String TOKEN = "token"; public static final String ASSIGNED_CASE_UUID = "assignedCaseUuid"; public static final String ASSIGNEMENT_DATE = "assignmentDate"; @@ -41,6 +40,7 @@ public class SurveyTokenIndexDto extends PseudonymizableIndexDto { private final String generatedDocumentUuid; private final String generatedDocumentName; private final String generatedDocumentMimeType; + private final String externalRespondentId; public SurveyTokenIndexDto( String uuid, @@ -54,7 +54,8 @@ public SurveyTokenIndexDto( String generatedDocumentUuid, String generatedDocumentName, String generatedDocumentMimeType, - Date generatedDocumentDate) { + Date generatedDocumentDate, + String externalRespondentId) { super(uuid); this.surveyUuid = surveyUuid; this.surveyName = surveyName; @@ -67,6 +68,7 @@ public SurveyTokenIndexDto( this.generatedDocumentName = generatedDocumentName; this.generatedDocumentMimeType = generatedDocumentMimeType; this.responseReceivedDate = generatedDocumentDate; + this.externalRespondentId = externalRespondentId; } public String getSurveyUuid() { @@ -116,4 +118,8 @@ public String getGeneratedDocumentMimeType() { public Date getResponseReceivedDate() { return responseReceivedDate; } + + public String getExternalRespondentId() { + return externalRespondentId; + } } diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/survey/alias/PathAliasFacade.java b/sormas-api/src/main/java/de/symeda/sormas/api/survey/alias/PathAliasFacade.java new file mode 100644 index 00000000000..8c6c458f552 --- /dev/null +++ b/sormas-api/src/main/java/de/symeda/sormas/api/survey/alias/PathAliasFacade.java @@ -0,0 +1,19 @@ +package de.symeda.sormas.api.survey.alias; + +import javax.ejb.Remote; + +/** + * Alias can be (but not exclusively) used for Field ids. + */ +@Remote +public interface PathAliasFacade { + + /** + * Makes it more "readable" by shortening physical paths into aliases per example FieldIds. + * + * @param path + * that may (or may not) contain physical paths. + * @return shortened path with aliases. + */ + String fetchAliasPath(String path); +} diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/survey/external/ExternalSurveyProviderFacade.java b/sormas-api/src/main/java/de/symeda/sormas/api/survey/external/ExternalSurveyProviderFacade.java new file mode 100644 index 00000000000..eb2482f42f6 --- /dev/null +++ b/sormas-api/src/main/java/de/symeda/sormas/api/survey/external/ExternalSurveyProviderFacade.java @@ -0,0 +1,23 @@ +package de.symeda.sormas.api.survey.external; + +import javax.ejb.Remote; + +import de.symeda.sormas.api.survey.external.views.ExternalSurveyView; + +/** + * To avoid integrating a specific survey-tool integration within SORMAS, this contract was specified to stay tool-agnostic. + */ +@Remote +public interface ExternalSurveyProviderFacade { + + /** + * Must return a view to display Survey-results from an external tool. + * + * @param externalSurveyId + * identifier in the external tool. + * @param externalRespondentId + * response identifier in the external tool. + * @return View that can be displayed within SORMAS. + */ + ExternalSurveyView getExternalSurveyView(String externalSurveyId, String externalRespondentId); +} diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/survey/external/views/ExternalSurveyView.java b/sormas-api/src/main/java/de/symeda/sormas/api/survey/external/views/ExternalSurveyView.java new file mode 100644 index 00000000000..cb6a1f04a39 --- /dev/null +++ b/sormas-api/src/main/java/de/symeda/sormas/api/survey/external/views/ExternalSurveyView.java @@ -0,0 +1,31 @@ +package de.symeda.sormas.api.survey.external.views; + +import java.io.Serializable; +import java.util.List; + +import de.symeda.sormas.api.audit.AuditedClass; + +/** + * View used to display an external Survey in an tool-agnostic manner. + */ +@AuditedClass +public class ExternalSurveyView implements Serializable { + + private static final long serialVersionUID = 1448651469231018412L; + + private List questionAnswersViews; + + public List getQuestionAnswersViews() { + return questionAnswersViews; + } + + public ExternalSurveyView setQuestionAnswersViews(List questionAnswersViews) { + this.questionAnswersViews = questionAnswersViews; + return this; + } + + @Override + public String toString() { + return "ExternalSurveyView{" + "questionAnswersViews=" + questionAnswersViews + '}'; + } +} diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/survey/external/views/QuestionAnswersView.java b/sormas-api/src/main/java/de/symeda/sormas/api/survey/external/views/QuestionAnswersView.java new file mode 100644 index 00000000000..81d181cb4f1 --- /dev/null +++ b/sormas-api/src/main/java/de/symeda/sormas/api/survey/external/views/QuestionAnswersView.java @@ -0,0 +1,96 @@ +package de.symeda.sormas.api.survey.external.views; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import javax.annotation.Nullable; +import javax.validation.constraints.NotNull; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +/** + * Agnostic-Wrapper to display a survey result in SORMAS from an external tool. + */ +public class QuestionAnswersView implements Serializable { + + private static final long serialVersionUID = -1635618566991671402L; + + @NotNull + private String question; + + @Nullable + private String answer; + + @JsonIgnore + @Nullable + private String answerText; + + /** + * Some questions can be grouped together. + * Example: PersonalInfo: + * - PhoneNumber + * - Email + */ + private List subquestions = new ArrayList<>(); + + public String getQuestion() { + return question; + } + + public QuestionAnswersView setQuestion(String question) { + this.question = question; + return this; + } + + public String getAnswer() { + return answer; + } + + public QuestionAnswersView setAnswer(String answer) { + this.answer = answer; + return this; + } + + public List getSubquestions() { + return subquestions; + } + + public QuestionAnswersView setSubquestions(List subquestions) { + this.subquestions = subquestions; + return this; + } + + @Nullable + public String getAnswerText() { + return answerText; + } + + public QuestionAnswersView setAnswerText(@Nullable String answerText) { + this.answerText = answerText; + return this; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) + return false; + QuestionAnswersView that = (QuestionAnswersView) o; + return Objects.equals(question, that.question) + && Objects.equals(answer, that.answer) + && Objects.equals(answerText, that.answerText) + && Objects.equals(subquestions, that.subquestions); + } + + @Override + public int hashCode() { + return Objects.hash(question, answer, answerText, subquestions); + } + + @Override + public String toString() { + return "QuestionAnswersView{" + "question='" + question + '\'' + ", answer='" + answer + '\'' + ", answerText='" + answerText + '\'' + + ", subquestions=" + subquestions + '}'; + } +} diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/symptoms/SymptomState.java b/sormas-api/src/main/java/de/symeda/sormas/api/symptoms/SymptomState.java index 8b0c7020c3b..9344df6a973 100644 --- a/sormas-api/src/main/java/de/symeda/sormas/api/symptoms/SymptomState.java +++ b/sormas-api/src/main/java/de/symeda/sormas/api/symptoms/SymptomState.java @@ -18,10 +18,12 @@ package de.symeda.sormas.api.symptoms; import de.symeda.sormas.api.i18n.I18nProperties; +import de.symeda.sormas.api.patch.mapping.ValueMapperDefault; public enum SymptomState { YES, + @ValueMapperDefault NO, UNKNOWN; diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/symptoms/SymptomsDto.java b/sormas-api/src/main/java/de/symeda/sormas/api/symptoms/SymptomsDto.java index 93bb725afe4..2155bed91a8 100644 --- a/sormas-api/src/main/java/de/symeda/sormas/api/symptoms/SymptomsDto.java +++ b/sormas-api/src/main/java/de/symeda/sormas/api/symptoms/SymptomsDto.java @@ -344,6 +344,14 @@ public class SymptomsDto extends PseudonymizableDto { public static final String WEIGHT_LOSS = "weightLoss"; public static final String WEIGHT_LOSS_AMOUNT = "weightLossAmount"; public static final String BLOATING = "bloating"; + + public static final String LOSS_OF_APPETITE = "lossOfAppetite"; + public static final String FLATULENCE = "flatulence"; + public static final String SMELLY_BURPS = "smellyBurps"; + public static final String COUGHING_ATTACKS = "coughingAttacks"; + public static final String COUGHING_AT_NIGHT = "coughingAtNight"; + public static final String ABDOMINAL_CRAMPS = "abdominalCramps"; + public static final String REOCCURRENCE = "reoccurrence"; public static final String OVERNIGHT_STAY_REQUIRED = "overnightStayRequired"; public static final String SYMPTOM_CURRENT_STATUS = "symptomCurrentStatus"; @@ -629,6 +637,7 @@ public static SymptomsDto build() { ANTHRAX, UNSPECIFIED_VHF, CORONAVIRUS, + PERTUSSIS, RESPIRATORY_SYNCYTIAL_VIRUS, UNDEFINED, OTHER }) @@ -1584,6 +1593,9 @@ public static SymptomsDto build() { PLAGUE, UNSPECIFIED_VHF, CONGENITAL_RUBELLA, + CRYPTOSPORIDIOSIS, + GIARDIASIS, + RESPIRATORY_SYNCYTIAL_VIRUS, POLIO, RABIES, CORONAVIRUS, @@ -2922,6 +2934,33 @@ public static SymptomsDto build() { @SymptomGrouping(SymptomGroup.GASTROINTESTINAL) private SymptomState bloating; + @Diseases({ + CRYPTOSPORIDIOSIS, + GIARDIASIS, + RESPIRATORY_SYNCYTIAL_VIRUS }) + private SymptomState lossOfAppetite; + + @Diseases({ + GIARDIASIS }) + private SymptomState flatulence; + + @Diseases({ + GIARDIASIS }) + private SymptomState smellyBurps; + + @Diseases({ + PERTUSSIS }) + private SymptomState coughingAttacks; + + @Diseases({ + PERTUSSIS }) + private SymptomState coughingAtNight; + + @Diseases({ + CRYPTOSPORIDIOSIS, + GIARDIASIS }) + private SymptomState abdominalCramps; + private DiagnosisType diagnosis; private InfectionSite majorSite; private String otherMajorSiteDetails; @@ -5256,4 +5295,51 @@ public void setEyeIrritation(SymptomState eyeIrritation) { this.eyeIrritation = eyeIrritation; } + public SymptomState getCoughingAtNight() { + return coughingAtNight; + } + + public void setCoughingAtNight(SymptomState coughingAtNight) { + this.coughingAtNight = coughingAtNight; + } + + public SymptomState getLossOfAppetite() { + return lossOfAppetite; + } + + public void setLossOfAppetite(SymptomState lossOfAppetite) { + this.lossOfAppetite = lossOfAppetite; + } + + public SymptomState getFlatulence() { + return flatulence; + } + + public void setFlatulence(SymptomState flatulence) { + this.flatulence = flatulence; + } + + public SymptomState getSmellyBurps() { + return smellyBurps; + } + + public void setSmellyBurps(SymptomState smellyBurps) { + this.smellyBurps = smellyBurps; + } + + public SymptomState getCoughingAttacks() { + return coughingAttacks; + } + + public void setCoughingAttacks(SymptomState coughingAttacks) { + this.coughingAttacks = coughingAttacks; + } + + public SymptomState getAbdominalCramps() { + return abdominalCramps; + } + + public void setAbdominalCramps(SymptomState abdominalCramps) { + this.abdominalCramps = abdominalCramps; + } } diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/user/DefaultUserRole.java b/sormas-api/src/main/java/de/symeda/sormas/api/user/DefaultUserRole.java index 2951456bcb2..84d864fcaac 100644 --- a/sormas-api/src/main/java/de/symeda/sormas/api/user/DefaultUserRole.java +++ b/sormas-api/src/main/java/de/symeda/sormas/api/user/DefaultUserRole.java @@ -1431,10 +1431,13 @@ public Set getDefaultUserRights() { EXTERNAL_MESSAGE_ACCESS, EXTERNAL_MESSAGE_LABORATORY_VIEW, EXTERNAL_MESSAGE_DOCTOR_DECLARATION_VIEW, + EXTERNAL_MESSAGE_SURVEY_RESPONSE_VIEW, EXTERNAL_MESSAGE_LABORATORY_PROCESS, EXTERNAL_MESSAGE_DOCTOR_DECLARATION_PROCESS, + EXTERNAL_MESSAGE_SURVEY_RESPONSE_PROCESS, EXTERNAL_MESSAGE_LABORATORY_DELETE, EXTERNAL_MESSAGE_DOCTOR_DECLARATION_DELETE, + EXTERNAL_MESSAGE_SURVEY_RESPONSE_DELETE, PERFORM_BULK_OPERATIONS, TRAVEL_ENTRY_MANAGEMENT_ACCESS, TRAVEL_ENTRY_VIEW, @@ -1477,7 +1480,16 @@ public Set getDefaultUserRights() { EXTERNAL_EMAIL_SEND, EXTERNAL_EMAIL_ATTACH_DOCUMENTS, SORMAS_REST, - SORMAS_UI)); + SORMAS_UI, + SURVEY_VIEW, + SURVEY_CREATE, + SURVEY_EDIT, + SURVEY_DELETE, + SURVEY_TOKEN_VIEW, + SURVEY_TOKEN_CREATE, + SURVEY_TOKEN_EDIT, + SURVEY_TOKEN_DELETE, + SURVEY_TOKEN_IMPORT)); break; case POE_INFORMANT: userRights.addAll( @@ -1811,6 +1823,7 @@ public Set getDefaultUserRights() { CONTACT_CONVERT, CONTACT_EXPORT, CONTACT_REASSIGN_CASE, + DOCUMENT_DELETE, MANAGE_EXTERNAL_SYMPTOM_JOURNAL, VISIT_EXPORT, VISIT_DELETE, @@ -1863,10 +1876,13 @@ public Set getDefaultUserRights() { EXTERNAL_MESSAGE_ACCESS, EXTERNAL_MESSAGE_LABORATORY_VIEW, EXTERNAL_MESSAGE_DOCTOR_DECLARATION_VIEW, + EXTERNAL_MESSAGE_SURVEY_RESPONSE_VIEW, EXTERNAL_MESSAGE_LABORATORY_PROCESS, EXTERNAL_MESSAGE_DOCTOR_DECLARATION_PROCESS, + EXTERNAL_MESSAGE_SURVEY_RESPONSE_PROCESS, EXTERNAL_MESSAGE_LABORATORY_DELETE, EXTERNAL_MESSAGE_DOCTOR_DECLARATION_DELETE, + EXTERNAL_MESSAGE_SURVEY_RESPONSE_DELETE, TRAVEL_ENTRY_MANAGEMENT_ACCESS, TRAVEL_ENTRY_VIEW, TRAVEL_ENTRY_CREATE, @@ -1886,7 +1902,16 @@ public Set getDefaultUserRights() { EXTERNAL_EMAIL_SEND, EXTERNAL_EMAIL_ATTACH_DOCUMENTS, SORMAS_REST, - SORMAS_UI)); + SORMAS_UI, + SURVEY_VIEW, + SURVEY_CREATE, + SURVEY_EDIT, + SURVEY_DELETE, + SURVEY_TOKEN_VIEW, + SURVEY_TOKEN_CREATE, + SURVEY_TOKEN_EDIT, + SURVEY_TOKEN_DELETE, + SURVEY_TOKEN_IMPORT)); break; default: throw new IllegalArgumentException(this.toString()); diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/user/UserRight.java b/sormas-api/src/main/java/de/symeda/sormas/api/user/UserRight.java index e2ab2daf826..88378b1e968 100644 --- a/sormas-api/src/main/java/de/symeda/sormas/api/user/UserRight.java +++ b/sormas-api/src/main/java/de/symeda/sormas/api/user/UserRight.java @@ -290,6 +290,7 @@ public enum UserRight { EXTERNAL_MESSAGE_ACCESS(UserRightGroup.EXTERNAL), EXTERNAL_MESSAGE_LABORATORY_VIEW(UserRightGroup.EXTERNAL, UserRight._EXTERNAL_MESSAGE_ACCESS), EXTERNAL_MESSAGE_DOCTOR_DECLARATION_VIEW(UserRightGroup.EXTERNAL, UserRight._EXTERNAL_MESSAGE_ACCESS), + EXTERNAL_MESSAGE_SURVEY_RESPONSE_VIEW(UserRightGroup.EXTERNAL, UserRight._EXTERNAL_MESSAGE_ACCESS), EXTERNAL_MESSAGE_LABORATORY_PROCESS(UserRightGroup.EXTERNAL, UserRight._EXTERNAL_MESSAGE_ACCESS, UserRight._EXTERNAL_MESSAGE_LABORATORY_VIEW, UserRight._SAMPLE_CREATE, UserRight._SAMPLE_EDIT, UserRight._PATHOGEN_TEST_CREATE, UserRight._PATHOGEN_TEST_EDIT, UserRight._PATHOGEN_TEST_DELETE, @@ -298,8 +299,18 @@ public enum UserRight { UserRight._SAMPLE_CREATE, UserRight._SAMPLE_EDIT, UserRight._PATHOGEN_TEST_CREATE, UserRight._PATHOGEN_TEST_EDIT, UserRight._PATHOGEN_TEST_DELETE, UserRight._IMMUNIZATION_CREATE, UserRight._IMMUNIZATION_EDIT, UserRight._IMMUNIZATION_DELETE), + EXTERNAL_MESSAGE_SURVEY_RESPONSE_PROCESS(UserRightGroup.EXTERNAL, UserRight._EXTERNAL_MESSAGE_ACCESS, UserRight._EXTERNAL_MESSAGE_SURVEY_RESPONSE_VIEW, + UserRight._SAMPLE_CREATE, UserRight._SAMPLE_EDIT, UserRight._PATHOGEN_TEST_CREATE, UserRight._PATHOGEN_TEST_EDIT, UserRight._PATHOGEN_TEST_DELETE, + UserRight._IMMUNIZATION_CREATE, UserRight._IMMUNIZATION_EDIT, UserRight._IMMUNIZATION_DELETE, + UserRight._SURVEY_VIEW, + UserRight._SURVEY_CREATE, + UserRight._SURVEY_EDIT, + UserRight._SURVEY_DELETE, + UserRight._SURVEY_TOKEN_VIEW), + EXTERNAL_MESSAGE_LABORATORY_DELETE(UserRightGroup.EXTERNAL, UserRight._EXTERNAL_MESSAGE_ACCESS, UserRight._EXTERNAL_MESSAGE_LABORATORY_VIEW), EXTERNAL_MESSAGE_DOCTOR_DECLARATION_DELETE(UserRightGroup.EXTERNAL, UserRight._EXTERNAL_MESSAGE_ACCESS, UserRight._EXTERNAL_MESSAGE_DOCTOR_DECLARATION_VIEW), + EXTERNAL_MESSAGE_SURVEY_RESPONSE_DELETE(UserRightGroup.EXTERNAL, UserRight._EXTERNAL_MESSAGE_ACCESS, UserRight._EXTERNAL_MESSAGE_SURVEY_RESPONSE_VIEW), SURVEY_VIEW(UserRightGroup.SURVEY), SURVEY_CREATE(UserRightGroup.SURVEY, UserRight._SURVEY_VIEW), @@ -511,6 +522,9 @@ public enum UserRight { public static final String _EXTERNAL_MESSAGE_DOCTOR_DECLARATION_VIEW = "EXTERNAL_MESSAGE_DOCTOR_DECLARATION_VIEW"; public static final String _EXTERNAL_MESSAGE_DOCTOR_DECLARATION_PROCESS = "EXTERNAL_MESSAGE_DOCTOR_DECLARATION_PROCESS"; public static final String _EXTERNAL_MESSAGE_DOCTOR_DECLARATION_DELETE = "EXTERNAL_MESSAGE_DOCTOR_DECLARATION_DELETE"; + public static final String _EXTERNAL_MESSAGE_SURVEY_RESPONSE_VIEW = "EXTERNAL_MESSAGE_SURVEY_RESPONSE_VIEW"; + public static final String _EXTERNAL_MESSAGE_SURVEY_RESPONSE_PROCESS = "EXTERNAL_MESSAGE_SURVEY_RESPONSE_PROCESS"; + public static final String _EXTERNAL_MESSAGE_SURVEY_RESPONSE_DELETE = "EXTERNAL_MESSAGE_SURVEY_RESPONSE_DELETE"; public static final String _TRAVEL_ENTRY_MANAGEMENT_ACCESS = "TRAVEL_ENTRY_MANAGEMENT_ACCESS"; public static final String _TRAVEL_ENTRY_VIEW = "TRAVEL_ENTRY_VIEW"; public static final String _TRAVEL_ENTRY_VIEW_ARCHIVED = "TRAVEL_ENTRY_VIEW_ARCHIVED"; diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/utils/OrderedRegisterable.java b/sormas-api/src/main/java/de/symeda/sormas/api/utils/OrderedRegisterable.java new file mode 100644 index 00000000000..e50b472a832 --- /dev/null +++ b/sormas-api/src/main/java/de/symeda/sormas/api/utils/OrderedRegisterable.java @@ -0,0 +1,72 @@ +package de.symeda.sormas.api.utils; + +import java.util.Set; + +import javax.validation.constraints.NotNull; + +/** + * Can be used to define ordered mappers (same order by default) that supports specific type and that perform whatever action on this type. + * Makes it simpler to register them into registries with: {@link Comparable} and the {@link #supports(Class)}. + * + * @param + * type of interface that extends this class. + */ +public interface OrderedRegisterable> extends Comparable { + + int HIGH_PRECEDENCE = Integer.MIN_VALUE; + + int LOW_PRECEDENCE = Integer.MAX_VALUE; + + /** + * Can be used to add it to the default precedences values and a keep some "space between" the implementations ordering. + */ + int ORDER_CHUNK = 20; + + /** + * Meant to be implemented by classes implementing this {@link OrderedRegisterable} contract but to be used. + * For usages prefer {@link #supports(Class)}. + * + * @return types that are supported by this class. + */ + @NotNull + Set> getSupportedTypes(); + + /** + * Specifies if the targetType is supported by this class. + * + * @param targetType + * can be a child class. + * @return true if the class will be able to perform some action with this type. + */ + default boolean supports(@NotNull Class targetType) { + + boolean directlySupportedType = getSupportedTypes().contains(targetType); + + if (directlySupportedType) { + return true; + } + + for (Class supported : getSupportedTypes()) { + if (supported.isAssignableFrom(targetType)) { + return true; + } + } + return false; + } + + /** + * Allows you to override default mappers. + * {@link #HIGH_PRECEDENCE} means this mapper will be used (among) first. + * {@link #LOW_PRECEDENCE} means this mapper will be used (among) last. + * + * @return defaults to LOW_PRECEDENCE + */ + default int getOrder() { + return LOW_PRECEDENCE; + } + + @Override + default int compareTo(SELF o) { + return Integer.compare(this.getOrder(), o.getOrder()); + } +} diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/utils/Tuple.java b/sormas-api/src/main/java/de/symeda/sormas/api/utils/Tuple.java new file mode 100644 index 00000000000..faf78241b7c --- /dev/null +++ b/sormas-api/src/main/java/de/symeda/sormas/api/utils/Tuple.java @@ -0,0 +1,44 @@ +package de.symeda.sormas.api.utils; + +import java.util.Objects; + +public class Tuple { + + private final F first; + private final S second; + + public static Tuple of(final F first, final S second) { + return new Tuple<>(first, second); + } + + public Tuple(final F first, final S second) { + this.first = first; + this.second = second; + } + + public F getFirst() { + return first; + } + + public S getSecond() { + return second; + } + + @Override + public String toString() { + return "Tuple{" + "first=" + first + ", second=" + second + '}'; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) + return false; + Tuple tuple = (Tuple) o; + return Objects.equals(first, tuple.first) && Objects.equals(second, tuple.second); + } + + @Override + public int hashCode() { + return Objects.hash(first, second); + } +} diff --git a/sormas-api/src/main/java/de/symeda/sormas/api/utils/YesNoUnknown.java b/sormas-api/src/main/java/de/symeda/sormas/api/utils/YesNoUnknown.java index 7d4cf2ca8ba..0f9e66a4110 100644 --- a/sormas-api/src/main/java/de/symeda/sormas/api/utils/YesNoUnknown.java +++ b/sormas-api/src/main/java/de/symeda/sormas/api/utils/YesNoUnknown.java @@ -18,9 +18,11 @@ package de.symeda.sormas.api.utils; import de.symeda.sormas.api.i18n.I18nProperties; +import de.symeda.sormas.api.patch.mapping.ValueMapperDefault; public enum YesNoUnknown { + @ValueMapperDefault YES, NO, UNKNOWN; @@ -34,7 +36,7 @@ public static YesNoUnknown valueOf(Boolean value) { if (value == null) { return null; - } else if (Boolean.TRUE.equals(value)) { + } else if (value) { return YES; } else { return NO; diff --git a/sormas-api/src/main/resources/captions.properties b/sormas-api/src/main/resources/captions.properties index 96428ae213f..0e52609720c 100644 --- a/sormas-api/src/main/resources/captions.properties +++ b/sormas-api/src/main/resources/captions.properties @@ -41,6 +41,7 @@ lastName=Last name menu=Menu moreActions=More name=Name +externalId=External ID options=Options regionName=Region system=System @@ -1765,6 +1766,7 @@ ExternalMessage.personNationalHealthId=National health ID ExternalMessage.personPhoneNumberType=Phone number type ExternalMessage.personCountry=Country externalMessageFetch=Fetch messages +surveyFetch=Fetch Surveys externalMessageProcess=Process externalMessageNoDisease=No disease found externalMessageNoNewMessages=No new messages available @@ -3820,3 +3822,30 @@ epipulseNewExport=New export epipulseActiveExports=Active Epipulse exports epipulseArchivedExports=Archived Epipulse exports epipulseAllExports=All Epipulse exports + +# Survey responses UI +surveyResponseField=Field +surveyResponseSubmittedValue=Submitted Value +surveyResponseCurrentCaseValue=Current Case Value +surveyResponseFailureCause=Failure Cause +surveyResponseDescription=Description +surveyResponseCaseLink=Case +surveyResponseApplied=Applied +surveyResponseUuid=Survey Response ID +surveyResponseGeneralInfo=External Message General Info +surveyResponseMetadata=Metadata +surveyResponsePatchDictionary=Patch Dictionary +surveyResponseExcludedFieldsDictionary=Ignored fields Dictionary +surveyResponseProcessingResult=Processing Result +surveyResponseValidFields=Valid Fields +surveyResponseIgnoreField=Ignore +surveyResponseKeyName=Field Key +actionCorrectAndReprocess=Correct & Reprocess +actionSaveAndReprocess=Save & Reprocess +surveyResponseExternalSurveyId=External Survey ID +surveyResponseToken=Token +surveyResponseRespondentId=Respondent ID +surveyResponseResponseReceivedDate=Response Received +surveyResponseReplacementStrategy=Replacement Strategy +surveyResponseEmptyValueBehavior=Empty Value Behavior +surveyResponsePatchedInCaseOfFailures=Patched in Case of Failures diff --git a/sormas-api/src/main/resources/enum.properties b/sormas-api/src/main/resources/enum.properties index 5f01e59b6c3..13b8d0b33c9 100644 --- a/sormas-api/src/main/resources/enum.properties +++ b/sormas-api/src/main/resources/enum.properties @@ -2564,6 +2564,7 @@ ExternalMessageStatus.UNCLEAR=Unclear # ExternalMessageType ExternalMessageType.LAB_MESSAGE=Lab message ExternalMessageType.PHYSICIANS_REPORT=Physician's report +ExternalMessageType.SURVEY_RESPONSE=Survey response # ShareRequestDataType ShareRequestDataType.CASE = Case @@ -3115,3 +3116,19 @@ AnimalCategory.WILD=Wild # FomiteTransmissionLocation FomiteTransmissionLocation.INSIDE_HOME=Inside home FomiteTransmissionLocation.OUTSIDE=Outside + +# DataPatchFailureCause +DataPatchFailureCause.INVALID_MULTIPLE_FIELDS_FORMAT=Invalid multiple fields format +DataPatchFailureCause.MISSING_MANDATORY_FIELD_FOR_GROUP=Missing required field for group +DataPatchFailureCause.FORBIDDEN_NON_UNIQUE_ALIAS=Alias must map to a single unique field +DataPatchFailureCause.NOT_PRESENT_IN_REFERENCE_DATA_LIST=Value not in allowed reference list +DataPatchFailureCause.UNSUPPORTED_FIELD_FOR_DISEASE_OR_COUNTRY_OR_FEATURE=Field not supported for this disease, country, or feature +DataPatchFailureCause.UNSUPPORTED_PREFIX=Unsupported path prefix +DataPatchFailureCause.FIELD_DOES_NOT_EXIST=Field does not exist +DataPatchFailureCause.FORBIDDEN_FIELD=Field cannot be modified +DataPatchFailureCause.FORBIDDEN_VALUE_OVERRIDE=Overriding existing value is not allowed +DataPatchFailureCause.INVALID_VALUE_TYPE=Invalid value type +DataPatchFailureCause.UNSUPPORTED_TARGET_TYPE=Unsupported target type +DataPatchFailureCause.INVALID_PATH_FORMAT=Invalid path format +DataPatchFailureCause.DUPLICATE_FIELD=Duplicate field — key appeared more than once in the input +DataPatchFailureCause.TECHNICAL=Technical error diff --git a/sormas-api/src/main/resources/strings.properties b/sormas-api/src/main/resources/strings.properties index 0a528270657..d071bf1507d 100644 --- a/sormas-api/src/main/resources/strings.properties +++ b/sormas-api/src/main/resources/strings.properties @@ -2066,4 +2066,11 @@ Vaccine.vaccinationType.PCV15=PCV15 Vaccine.vaccinationType.PCV13=PCV13 Vaccine.vaccinationType.PCV20=PCV20 exposureStartDate = Exposure start date -exposureEndDate = Exposure end date \ No newline at end of file +exposureEndDate = Exposure end date + +headingSurveyResponseDetails=Response Details +headingSurveyResponseFailures=Validation Issues +headingSurveyResponseCorrectAndReprocess=Correct & Reprocess +messageSurveyResponseAllFieldsApplied=All fields were successfully applied +messageSurveyResponseNotYetProcessed=This response has not been processed yet +messageSurveyResponseReprocessed=The response has been successfully reprocessed diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/ReferenceDataValueInstanceProviderImpl.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/ReferenceDataValueInstanceProviderImpl.java new file mode 100644 index 00000000000..a41c2ade05c --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/ReferenceDataValueInstanceProviderImpl.java @@ -0,0 +1,83 @@ +package de.symeda.sormas.backend; + +import java.util.*; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +import javax.annotation.PostConstruct; +import javax.enterprise.context.ApplicationScoped; + +import de.symeda.sormas.api.InfrastructureDataReferenceDto; +import de.symeda.sormas.api.ReferenceDto; +import de.symeda.sormas.api.infrastructure.community.CommunityDto; +import de.symeda.sormas.api.infrastructure.community.CommunityFacade; +import de.symeda.sormas.api.infrastructure.community.CommunityReferenceDto; +import de.symeda.sormas.api.infrastructure.country.CountryFacade; +import de.symeda.sormas.api.infrastructure.country.CountryReferenceDto; +import de.symeda.sormas.api.infrastructure.district.DistrictFacade; +import de.symeda.sormas.api.infrastructure.district.DistrictReferenceDto; +import de.symeda.sormas.api.infrastructure.facility.FacilityDto; +import de.symeda.sormas.api.infrastructure.facility.FacilityFacade; +import de.symeda.sormas.api.infrastructure.facility.FacilityReferenceDto; +import de.symeda.sormas.api.infrastructure.pointofentry.PointOfEntryDto; +import de.symeda.sormas.api.infrastructure.pointofentry.PointOfEntryFacade; +import de.symeda.sormas.api.infrastructure.pointofentry.PointOfEntryReferenceDto; +import de.symeda.sormas.api.infrastructure.region.RegionFacade; +import de.symeda.sormas.api.infrastructure.region.RegionReferenceDto; +import de.symeda.sormas.api.referencedata.ReferenceDataValueInstanceProvider; +import de.symeda.sormas.backend.util.InstanceProvider; +import de.symeda.sormas.backend.util.StringNormalizer; + +@ApplicationScoped +public class ReferenceDataValueInstanceProviderImpl implements ReferenceDataValueInstanceProvider { + + public static final Date DATE_ALL_VALUES = new Date(0); + private Map, Supplier>> dictionary; + + @PostConstruct + private void init() { + dictionary = Map.ofEntries( + Map.entry(CountryReferenceDto.class, () -> getInstance(CountryFacade.class).getAllActiveAsReference()), + Map.entry(RegionReferenceDto.class, () -> getInstance(RegionFacade.class).getAllActiveAsReference()), + Map.entry(DistrictReferenceDto.class, () -> getInstance(DistrictFacade.class).getAllActiveAsReference()), + Map.entry( + CommunityReferenceDto.class, + () -> getInstance(CommunityFacade.class).getAllAfter(DATE_ALL_VALUES) + .stream() + .map(CommunityDto::toReference) + .collect(Collectors.toList())), + Map.entry( + FacilityReferenceDto.class, + () -> getInstance(FacilityFacade.class).getAllWithoutRegionAfter(DATE_ALL_VALUES) + .stream() + .map(FacilityDto::toReference) + .collect(Collectors.toList())), + Map.entry( + PointOfEntryReferenceDto.class, + () -> getInstance(PointOfEntryFacade.class).getAllAfter(DATE_ALL_VALUES) + .stream() + .map(PointOfEntryDto::toReference) + .collect(Collectors.toList()))); + } + + private T getInstance(Class ejb) { + return InstanceProvider.getInstanceFor(ejb); + } + + @Override + public List getAll(Class referenceType) { + return (List) Optional.ofNullable(dictionary.get(referenceType).get()).orElse(Collections.emptyList()); + } + + @Override + public Optional getOne(String caption, Class referenceType) { + return getAll(referenceType).stream().filter(referenceDto -> { + String normalizedCaptionCandidate = StringNormalizer.normalize(caption); + return StringNormalizer.normalize(referenceDto.getCaption()).equals(normalizedCaptionCandidate) + && (referenceDto instanceof InfrastructureDataReferenceDto + && normalizedCaptionCandidate + .equals(StringNormalizer.normalize(((InfrastructureDataReferenceDto) referenceDto).getExternalId()))); + }).findAny(); + } + +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/common/CronService.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/common/CronService.java index 14223739d9f..7283266242b 100644 --- a/sormas-backend/src/main/java/de/symeda/sormas/backend/common/CronService.java +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/common/CronService.java @@ -20,6 +20,8 @@ import java.nio.file.Files; import java.nio.file.attribute.BasicFileAttributes; import java.util.Date; +import java.util.List; +import java.util.stream.Collectors; import javax.annotation.security.RunAs; import javax.ejb.EJB; @@ -30,6 +32,7 @@ import org.slf4j.LoggerFactory; import de.symeda.sormas.api.common.DeletableEntityType; +import de.symeda.sormas.api.externalmessage.ExternalMessageDto; import de.symeda.sormas.api.feature.FeatureType; import de.symeda.sormas.api.feature.FeatureTypeProperty; import de.symeda.sormas.api.importexport.ImportExportUtils; @@ -229,6 +232,24 @@ public void fetchExternalMessages() { } } + @Schedule(hour = "*", persistent = false) + public void fetchSurveyResponses() { + if (!featureConfigurationFacade.isFeatureEnabled(FeatureType.EXTERNAL_MESSAGES) + || featureConfigurationFacade.isPropertyValueTrue(FeatureType.EXTERNAL_MESSAGES, FeatureTypeProperty.SURVEY_FETCH_ENABLED)) { + logger.info("External messages are disabled, survey responses will not be fetched"); + return; + } + + List surveyExternalMessages = externalMessageFacade.saveAndProcessSurveyResponses(); + + if (logger.isInfoEnabled()) { + List reportIds = surveyExternalMessages.stream().map(ExternalMessageDto::getReportId).collect(Collectors.toList()); + if (!reportIds.isEmpty()) { + logger.info("Survey responses with following reportIds were saved: [{}]", reportIds); + } + } + } + @Schedule(hour = "1", minute = "40", second = "0", persistent = false) public void updateImmunizationStatuses() { if (featureConfigurationFacade.isFeatureEnabled(FeatureType.IMMUNIZATION_STATUS_AUTOMATION)) { diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/externalmessage/ExternalMessage.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/externalmessage/ExternalMessage.java index 8be2ba15a30..ac9275b8170 100644 --- a/sormas-backend/src/main/java/de/symeda/sormas/backend/externalmessage/ExternalMessage.java +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/externalmessage/ExternalMessage.java @@ -242,6 +242,10 @@ public class ExternalMessage extends AbstractDomainObject { private ModeOfTransmission modeOfTransmission; private String modeOfTransmissionType; + private ExternalMessageAdditionalDataType additionalDataType; + + private String additionalDataJson; + @Enumerated(EnumType.STRING) public ExternalMessageType getType() { return type; @@ -1019,4 +1023,25 @@ public void setModeOfTransmissionType(String modeOfTransmissionType) { this.modeOfTransmissionType = modeOfTransmissionType; } + + @Enumerated(EnumType.STRING) + public ExternalMessageAdditionalDataType getAdditionalDataType() { + return additionalDataType; + } + + public ExternalMessage setAdditionalDataType(ExternalMessageAdditionalDataType additionalDataType) { + this.additionalDataType = additionalDataType; + return this; + } + + @Column(columnDefinition = "jsonb") + @Type(type = "jsonb") + public String getAdditionalDataJson() { + return additionalDataJson; + } + + public ExternalMessage setAdditionalDataJson(String additionalData) { + this.additionalDataJson = additionalData; + return this; + } } diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/externalmessage/ExternalMessageAdditionalDataType.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/externalmessage/ExternalMessageAdditionalDataType.java new file mode 100644 index 00000000000..e89075412f3 --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/externalmessage/ExternalMessageAdditionalDataType.java @@ -0,0 +1,31 @@ +package de.symeda.sormas.backend.externalmessage; + +import java.util.Arrays; +import java.util.Optional; + +import de.symeda.sormas.api.externalmessage.survey.ExternalSurveyResponseData; + +/** + * Allows to detach the actual type name from the DB. + * Avoids to handle things like: "class-injection" or breaking behavior when changing className. + *

+ * For versioning purposes a new entry can be added like: CURRENT_MEMBER_NAME_V2. + */ +public enum ExternalMessageAdditionalDataType { + + SURVEY_RESPONSE_DATA(ExternalSurveyResponseData.class); + + private final Class dataClass; + + ExternalMessageAdditionalDataType(Class dataClass) { + this.dataClass = dataClass; + } + + public Class getDataClass() { + return dataClass; + } + + public static Optional from(Class dataClass) { + return Arrays.stream(values()).filter(type -> type.dataClass.equals(dataClass)).findFirst(); + } +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/externalmessage/ExternalMessageFacadeEjb.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/externalmessage/ExternalMessageFacadeEjb.java index 3322272908f..aa60826060c 100644 --- a/sormas-backend/src/main/java/de/symeda/sormas/backend/externalmessage/ExternalMessageFacadeEjb.java +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/externalmessage/ExternalMessageFacadeEjb.java @@ -17,12 +17,7 @@ import static java.util.stream.Collectors.toList; -import java.util.ArrayList; -import java.util.Collections; -import java.util.Date; -import java.util.List; -import java.util.Objects; -import java.util.Set; +import java.util.*; import java.util.concurrent.ExecutionException; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -31,21 +26,14 @@ import javax.ejb.EJB; import javax.ejb.LocalBean; import javax.ejb.Stateless; +import javax.inject.Inject; import javax.naming.CannotProceedException; import javax.naming.InitialContext; import javax.naming.NamingException; import javax.persistence.EntityManager; import javax.persistence.PersistenceContext; import javax.persistence.Tuple; -import javax.persistence.criteria.CriteriaBuilder; -import javax.persistence.criteria.CriteriaQuery; -import javax.persistence.criteria.Expression; -import javax.persistence.criteria.Join; -import javax.persistence.criteria.JoinType; -import javax.persistence.criteria.Order; -import javax.persistence.criteria.Predicate; -import javax.persistence.criteria.Root; -import javax.persistence.criteria.Selection; +import javax.persistence.criteria.*; import javax.validation.Valid; import javax.validation.constraints.NotNull; @@ -54,6 +42,9 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.fasterxml.jackson.core.JsonProcessingException; + +import de.symeda.sormas.api.EntityDto; import de.symeda.sormas.api.ReferenceDto; import de.symeda.sormas.api.caze.CaseReferenceDto; import de.symeda.sormas.api.caze.surveillancereport.SurveillanceReportReferenceDto; @@ -64,34 +55,33 @@ import de.symeda.sormas.api.customizableenum.CustomEnumNotFoundException; import de.symeda.sormas.api.customizableenum.CustomizableEnumType; import de.symeda.sormas.api.event.EventParticipantReferenceDto; -import de.symeda.sormas.api.externalmessage.ExternalMessageAdapterFacade; -import de.symeda.sormas.api.externalmessage.ExternalMessageCriteria; -import de.symeda.sormas.api.externalmessage.ExternalMessageDto; -import de.symeda.sormas.api.externalmessage.ExternalMessageFacade; -import de.symeda.sormas.api.externalmessage.ExternalMessageFetchResult; -import de.symeda.sormas.api.externalmessage.ExternalMessageIndexDto; -import de.symeda.sormas.api.externalmessage.ExternalMessageReferenceDto; -import de.symeda.sormas.api.externalmessage.ExternalMessageResult; -import de.symeda.sormas.api.externalmessage.ExternalMessageStatus; -import de.symeda.sormas.api.externalmessage.ExternalMessageType; -import de.symeda.sormas.api.externalmessage.NewMessagesState; +import de.symeda.sormas.api.externalmessage.*; import de.symeda.sormas.api.externalmessage.labmessage.SampleReportDto; import de.symeda.sormas.api.externalmessage.processing.ExternalMessageProcessingResult; +import de.symeda.sormas.api.externalmessage.survey.ExternalMessageSurveyResponseRequest; +import de.symeda.sormas.api.externalmessage.survey.ExternalMessageSurveyResponseWrapper; +import de.symeda.sormas.api.externalmessage.survey.ExternalSurveyResponseData; +import de.symeda.sormas.api.externalmessage.survey.SurveyAsExternalMessageAdapterFacade; import de.symeda.sormas.api.feature.FeatureType; import de.symeda.sormas.api.feature.FeatureTypeProperty; import de.symeda.sormas.api.i18n.Captions; import de.symeda.sormas.api.i18n.I18nProperties; import de.symeda.sormas.api.i18n.Strings; import de.symeda.sormas.api.i18n.Validations; +import de.symeda.sormas.api.patch.DataReplacementStrategy; +import de.symeda.sormas.api.patch.partial_retrieval.DisplayablePartialRetrievalResponse; import de.symeda.sormas.api.sample.PathogenTestResultType; import de.symeda.sormas.api.sample.SampleReferenceDto; +import de.symeda.sormas.api.systemconfiguration.SystemConfigurationValueFacade; import de.symeda.sormas.api.systemevents.SystemEventDto; import de.symeda.sormas.api.systemevents.SystemEventType; import de.symeda.sormas.api.user.UserReferenceDto; import de.symeda.sormas.api.user.UserRight; +import de.symeda.sormas.api.utils.DateHelper; import de.symeda.sormas.api.utils.SortProperty; import de.symeda.sormas.api.utils.ValidationRuntimeException; import de.symeda.sormas.api.utils.dataprocessing.ProcessingResult; +import de.symeda.sormas.api.utils.dataprocessing.ProcessingResultStatus; import de.symeda.sormas.backend.caze.surveillancereport.SurveillanceReport; import de.symeda.sormas.backend.caze.surveillancereport.SurveillanceReportFacadeEjb; import de.symeda.sormas.backend.caze.surveillancereport.SurveillanceReportService; @@ -102,29 +92,33 @@ import de.symeda.sormas.backend.externalmessage.labmessage.SampleReport; import de.symeda.sormas.backend.externalmessage.labmessage.SampleReportFacadeEjb; import de.symeda.sormas.backend.externalmessage.labmessage.TestReport; +import de.symeda.sormas.backend.externalmessage.survey.AutomaticSurveyResponseProcessor; +import de.symeda.sormas.backend.externalmessage.survey.SurveyResponseProcessingResult; import de.symeda.sormas.backend.feature.FeatureConfigurationFacadeEjb.FeatureConfigurationFacadeEjbLocal; import de.symeda.sormas.backend.infrastructure.country.CountryFacadeEjb; import de.symeda.sormas.backend.infrastructure.country.CountryService; import de.symeda.sormas.backend.infrastructure.facility.FacilityFacadeEjb; import de.symeda.sormas.backend.infrastructure.facility.FacilityService; +import de.symeda.sormas.backend.json.ObjectMapperProvider; import de.symeda.sormas.backend.sample.SampleService; import de.symeda.sormas.backend.symptoms.SymptomsFacadeEjb; import de.symeda.sormas.backend.systemevent.sync.SyncFacadeEjb; import de.symeda.sormas.backend.user.User; import de.symeda.sormas.backend.user.UserService; -import de.symeda.sormas.backend.util.DtoHelper; -import de.symeda.sormas.backend.util.IterableHelper; -import de.symeda.sormas.backend.util.ModelConstants; -import de.symeda.sormas.backend.util.QueryHelper; -import de.symeda.sormas.backend.util.RightsAllowed; +import de.symeda.sormas.backend.util.*; @Stateless(name = "ExternalMessageFacade") @RightsAllowed({ UserRight._EXTERNAL_MESSAGE_ACCESS, UserRight._EXTERNAL_MESSAGE_LABORATORY_VIEW, - UserRight._EXTERNAL_MESSAGE_DOCTOR_DECLARATION_VIEW }) + UserRight._EXTERNAL_MESSAGE_DOCTOR_DECLARATION_VIEW, + UserRight._EXTERNAL_MESSAGE_SURVEY_RESPONSE_VIEW }) public class ExternalMessageFacadeEjb implements ExternalMessageFacade { + private static final String SURVEY_PERIOD_INTERVAL_DAYS_CONFIG_KEY = "SURVEY_PERIOD_INTERVAL_DAYS"; + private static final String SURVEY_AS_EXTERNAL_MESSAGE_ADAPTER_JNDI_CONFIG_KEY = "SURVEY_AS_EXTERNAL_MESSAGE_ADAPTER_JNDI_KEY"; + public static final String DEFAULT_SURVEY_PERIOD_INTERVAL_DAYS = "5"; + private final Logger logger = LoggerFactory.getLogger(getClass()); @PersistenceContext(unitName = ModelConstants.PERSISTENCE_UNIT_NAME) @@ -157,6 +151,12 @@ public class ExternalMessageFacadeEjb implements ExternalMessageFacade { private AutomaticLabMessageProcessor automaticLabMessageProcessor; @EJB private FeatureConfigurationFacadeEjbLocal featureConfigurationFacade; + @Inject + private AutomaticSurveyResponseProcessor automaticSurveyResponseProcessor; + @Inject + private de.symeda.sormas.backend.patch.partial_retrieval.PartialRetrieverImpl partialRetriever; + @EJB + private SystemConfigurationValueFacade systemConfigurationValueFacade; ExternalMessage fillOrBuildEntity(@NotNull ExternalMessageDto source, ExternalMessage target, boolean checkChangeDate) { @@ -261,6 +261,14 @@ ExternalMessage fillOrBuildEntity(@NotNull ExternalMessageDto source, ExternalMe target.setHealthcareProfessional(source.getHealthcareProfessional()); target.setModeOfTransmission(source.getModeOfTransmission()); target.setModeOfTransmissionType(source.getModeOfTransmissionType()); + + ExternalSurveyResponseData surveyResponseData = source.getSurveyResponseData(); + + if (surveyResponseData != null) { + target.setAdditionalDataType(ExternalMessageAdditionalDataType.SURVEY_RESPONSE_DATA); + target.setAdditionalDataJson(ObjectMapperProvider.writeValueAsStringFailSafe(surveyResponseData)); + } + return target; } @@ -284,6 +292,83 @@ private ExternalMessageDto saveWithFallback(ExternalMessageDto dto) { } } + @Override + @RightsAllowed(UserRight._EXTERNAL_MESSAGE_SURVEY_RESPONSE_PROCESS) + public List saveAndProcessSurveyResponses(Date since) { + + if (since == null) { + int dateRange = Integer.parseInt( + Optional.ofNullable(systemConfigurationValueFacade.getValue(SURVEY_PERIOD_INTERVAL_DAYS_CONFIG_KEY)) + .orElse(DEFAULT_SURVEY_PERIOD_INTERVAL_DAYS)); + + since = DateHelper.addDays(new Date(), -dateRange); + } + + logger.debug("Since date: [{}] to fetch external survey responses", since); + + ExternalMessageAdapterFacade externalLabResultsFacade = getExternalSurveyProviderFacade(); + ExternalMessageResult> externalMessagesResult = externalLabResultsFacade.getExternalMessages(since); + List surveyResponses = externalMessagesResult.getValue(); + + List reportIds = surveyResponses.stream().map(ExternalMessageDto::getReportId).filter(Objects::nonNull).collect(toList()); + + if (!reportIds.isEmpty()) { + logger.debug("ReportIds that will be processed: [{}]", reportIds); + Map> statusUuidTuplesByReportId = + externalMessageService.getUuidsByReportIds(reportIds); + + Set unProcessedMessagesReportIds = statusUuidTuplesByReportId.entrySet() + .stream() + .filter(tuple -> tuple.getValue().getFirst() == ExternalMessageStatus.UNPROCESSED) + .map(Map.Entry::getKey) + .collect(Collectors.toSet()); + + Set alreadyPresentReportIds = statusUuidTuplesByReportId.keySet(); + + surveyResponses.forEach(dto -> { + de.symeda.sormas.api.utils.Tuple tuple = statusUuidTuplesByReportId.get(dto.getReportId()); + if (tuple != null && tuple.getFirst() == ExternalMessageStatus.UNPROCESSED) { + dto.setUuid(tuple.getSecond()); + } + }); + + surveyResponses = surveyResponses.stream() + .filter( + externalMessage -> !alreadyPresentReportIds.contains(externalMessage.getReportId()) + || unProcessedMessagesReportIds.contains(externalMessage.getReportId())) + .collect(toList()); + + if (logger.isTraceEnabled()) { + logger.trace("Computed survey responses: \n{}", ObjectMapperProvider.writeValueAsStringFailSafe(surveyResponses)); + } + } + + List savedDtos; + try { + List processingResults = automaticSurveyResponseProcessor.processSurveyResponses(surveyResponses); + + processingResults.forEach(wrapper -> { + ProcessingResultStatus result = wrapper.getResultStatus(); + if (result.isCanceled()) { + logger.error("Processing of surveyResponse with UUID {} has been canceled", wrapper.getExternalMessage().getUuid()); + } + }); + } catch (InterruptedException e) { + logger.error("Could not process lab message with UUID [{}]", extractUuids(surveyResponses), e); + Thread.currentThread().interrupt(); + } catch (ExecutionException e) { + logger.error("Could not process survey responses with UUID [{}]", extractUuids(surveyResponses), e); + } finally { + savedDtos = surveyResponses.stream().map(this::save).collect(toList()); + } + + if (logger.isTraceEnabled()) { + logger.trace("Saved survey responses external messages: \n{}", ObjectMapperProvider.writeValueAsStringFailSafe(savedDtos)); + } + + return savedDtos; + } + @Override public ExternalMessageDto save(@Valid ExternalMessageDto dto) { return save(dto, true, false); @@ -313,6 +398,10 @@ public ExternalMessageDto saveAndProcessLabmessage(@Valid ExternalMessageDto lab return getByUuid(labMessage.getUuid()); } + private List extractUuids(List dtos) { + return dtos.stream().map(EntityDto::getUuid).filter(Objects::nonNull).collect(toList()); + } + private boolean checkAutomaticProcessingAllowed() { return featureConfigurationFacade.isPropertyValueTrue(FeatureType.EXTERNAL_MESSAGES, FeatureTypeProperty.FORCE_AUTOMATIC_PROCESSING) || !featureConfigurationFacade.isAnyFeatureEnabled(FeatureType.CONTACT_TRACING, FeatureType.EVENT_SURVEILLANCE); @@ -488,6 +577,25 @@ public ExternalMessageDto toDto(ExternalMessage source) { target.setHealthcareProfessional(source.getHealthcareProfessional()); target.setModeOfTransmission(source.getModeOfTransmission()); target.setModeOfTransmissionType(source.getModeOfTransmissionType()); + + ExternalMessageAdditionalDataType additionalDataType = source.getAdditionalDataType(); + String additionalDataJson = source.getAdditionalDataJson(); + if (additionalDataType != null && additionalDataJson != null) { + try { + Object additionalDataInstance = ObjectMapperProvider.getInstance().readValue(additionalDataJson, additionalDataType.getDataClass()); + if (additionalDataInstance instanceof ExternalSurveyResponseData) { + target.setSurveyResponseData((ExternalSurveyResponseData) additionalDataInstance); + } else { + throw new IllegalStateException( + String.format( + "Unexpected additionalDataType: [%s], cannot be mapped into the DTO", + additionalDataInstance.getClass().getName())); + } + } catch (JsonProcessingException e) { + throw new RuntimeException("Couldn't read additionalData into: ExternalSurveyResponseData", e); + } + } + return target; } @@ -499,7 +607,8 @@ public ExternalMessageDto getByUuid(String uuid) { @Override @RightsAllowed({ UserRight._EXTERNAL_MESSAGE_LABORATORY_DELETE, - UserRight._EXTERNAL_MESSAGE_DOCTOR_DECLARATION_DELETE }) + UserRight._EXTERNAL_MESSAGE_DOCTOR_DECLARATION_DELETE, + UserRight._EXTERNAL_MESSAGE_SURVEY_RESPONSE_DELETE }) public void delete(String uuid) { externalMessageService.deletePermanent(externalMessageService.getByUuid(uuid)); } @@ -507,7 +616,8 @@ public void delete(String uuid) { @Override @RightsAllowed({ UserRight._EXTERNAL_MESSAGE_LABORATORY_DELETE, - UserRight._EXTERNAL_MESSAGE_DOCTOR_DECLARATION_DELETE }) + UserRight._EXTERNAL_MESSAGE_DOCTOR_DECLARATION_DELETE, + UserRight._EXTERNAL_MESSAGE_SURVEY_RESPONSE_DELETE, }) public List delete(List uuids) { List processedExternalMessages = new ArrayList<>(); List externalMessagesToBeDeleted = externalMessageService.getByUuids(uuids); @@ -784,6 +894,35 @@ private ExternalMessageAdapterFacade getExternalLabResultsFacade() throws Naming return (ExternalMessageAdapterFacade) ic.lookup(jndiName); } + private ExternalMessageAdapterFacade getSurveyExternalMessageFacade() { + try { + InitialContext ic = new InitialContext(); + String jndiName = configFacade.getExternalMessageAdapterJndiName(); + + if (jndiName == null) { + throw new CannotProceedException(I18nProperties.getValidationError(Validations.externalMessageConfigError)); + } + + return (ExternalMessageAdapterFacade) ic.lookup(jndiName); + } catch (NamingException e) { + throw new RuntimeException("Could not create of instance of SurveyExternalMessageFacade", e); + } + } + + private SurveyAsExternalMessageAdapterFacade getExternalSurveyProviderFacade() { + String jndiName = + Optional.ofNullable(systemConfigurationValueFacade.getValue(SURVEY_AS_EXTERNAL_MESSAGE_ADAPTER_JNDI_CONFIG_KEY)).orElseGet(() -> { + String defaultName = "java:global/sormas-esante-adapter/SurveyExternalMessageAdapterFacadeEjb"; + logger.info("External Survey Provider JNDI Key not found, using default: [{}]", defaultName); + return defaultName; + }); + try { + return (SurveyAsExternalMessageAdapterFacade) new InitialContext().lookup(jndiName); + } catch (NamingException e) { + throw new RuntimeException("Could not look up SurveyAsExternalMessageAdapterFacade via JNDI: " + jndiName, e); + } + } + @Override @PermitAll public String getExternalMessagesAdapterVersion() throws NamingException { @@ -860,6 +999,77 @@ public ExternalMessageDto getForSurveillanceReport(SurveillanceReportReferenceDt return toDto(externalMessageService.getForSurveillanceReport(surveillanceReport)); } + @Override + @RightsAllowed(UserRight._EXTERNAL_MESSAGE_SURVEY_RESPONSE_PROCESS) + public ExternalMessageDto overwriteSurveyResponse(String uuid, java.util.Map correctedDictionary) { + logger.debug("overwriteSurveyResponse: [{}],[{}]", uuid, correctedDictionary); + ExternalMessageDto externalMessage = getByUuid(uuid); + ExternalMessageSurveyResponseRequest latestRequest = externalMessage.getSurveyResponseData().getLatest().getRequest(); + + logger.info("On reprocessing replacement strategy is set to ALWAYS to allow override values, enable debug to see request"); + logger.debug("Request before transformation: [{}]", latestRequest); + + ExternalMessageSurveyResponseRequest correctedRequest = new ExternalMessageSurveyResponseRequest().setToken(latestRequest.getToken()) + .setExternalSurveyId(latestRequest.getExternalSurveyId()) + .setExternalRespondentId(latestRequest.getExternalRespondentId()) + .setResponseReceivedDate(latestRequest.getResponseReceivedDate()) + .setReplacementStrategy(DataReplacementStrategy.ALWAYS) + .setEmptyValueBehavior(latestRequest.getEmptyValueBehavior()) + .setOrigin(latestRequest.getOrigin()) + .setInputLanguages(latestRequest.getInputLanguages()) + .setAllowFallbackValues(latestRequest.isAllowFallbackValues()) + .setSkipIfAlreadyProcessed(latestRequest.isSkipIfAlreadyProcessed()) + .setPatchedInCaseOfFailures(latestRequest.isPatchedInCaseOfFailures()) + .setPatchDictionary(correctedDictionary); + + logger.debug("Request after transformation: [{}]", correctedRequest); + + ExternalMessageSurveyResponseWrapper updatedWrapper = new ExternalMessageSurveyResponseWrapper().setRequest(correctedRequest); + externalMessage.getSurveyResponseData().setUpdated(updatedWrapper); + + try { + automaticSurveyResponseProcessor.processSurveyResponses(java.util.List.of(externalMessage)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Interrupted while reprocessing survey response", e); + } catch (java.util.concurrent.ExecutionException e) { + throw new RuntimeException("Error while reprocessing survey response", e); + } + + return save(externalMessage); + } + + @Override + public de.symeda.sormas.api.patch.partial_retrieval.DisplayablePartialRetrievalResponse fetchSurveyResponseFieldsForDisplay( + String externalMessageUuid) { + + ExternalMessageDto externalMessage = getByUuid(externalMessageUuid); + de.symeda.sormas.api.externalmessage.survey.ExternalMessageSurveyResponseWrapper latest = externalMessage.getSurveyResponseData().getLatest(); + de.symeda.sormas.api.externalmessage.survey.ExternalMessageSurveyResponseResult result = latest.getResult(); + + if (result == null || result.getCaseUuid() == null) { + logger.warn("Result was null or not link to a caseUuid, this should not occur. [{}],[{}]", externalMessage, latest); + return new de.symeda.sormas.api.patch.partial_retrieval.DisplayablePartialRetrievalResponse(); + } + + java.util.Map patchDictionary = latest.getRequest().getPatchDictionary(); + if (patchDictionary == null || patchDictionary.isEmpty()) { + logger.warn("patchDictionary was null or emtpy, this should not occur. [{}],[{}]", externalMessage, latest); + return new de.symeda.sormas.api.patch.partial_retrieval.DisplayablePartialRetrievalResponse(); + } + + java.util.Set fieldPaths = patchDictionary.keySet(); + + de.symeda.sormas.api.patch.partial_retrieval.PartialRetrievalRequest request = + new de.symeda.sormas.api.patch.partial_retrieval.PartialRetrievalRequest().setCaseUuid(result.getCaseUuid()) + .setFieldsToRetrieve(fieldPaths); + + DisplayablePartialRetrievalResponse response = partialRetriever.retrievePartialForDisplay(request); + logger.debug("retrieveSurveyResponseFieldsForDisplay: [{}]", response); + + return response; + } + public static ExternalMessageReferenceDto toReferenceDto(ExternalMessage entity) { if (entity == null) { diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/externalmessage/ExternalMessageService.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/externalmessage/ExternalMessageService.java index 5e9dddf0776..511adf9c0da 100644 --- a/sormas-backend/src/main/java/de/symeda/sormas/backend/externalmessage/ExternalMessageService.java +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/externalmessage/ExternalMessageService.java @@ -1,29 +1,18 @@ package de.symeda.sormas.backend.externalmessage; -import java.util.ArrayList; -import java.util.Calendar; -import java.util.Collections; -import java.util.List; - -import javax.ejb.EJB; -import javax.ejb.LocalBean; -import javax.ejb.Stateless; -import javax.ejb.TransactionAttribute; -import javax.ejb.TransactionAttributeType; -import javax.persistence.criteria.CriteriaBuilder; -import javax.persistence.criteria.CriteriaQuery; -import javax.persistence.criteria.From; -import javax.persistence.criteria.Join; -import javax.persistence.criteria.JoinType; -import javax.persistence.criteria.Path; -import javax.persistence.criteria.Predicate; -import javax.persistence.criteria.Root; +import java.util.*; +import java.util.stream.Collectors; + +import javax.ejb.*; +import javax.persistence.Tuple; +import javax.persistence.criteria.*; import org.apache.commons.lang3.StringUtils; import de.symeda.sormas.api.ReferenceDto; import de.symeda.sormas.api.caze.surveillancereport.SurveillanceReportReferenceDto; import de.symeda.sormas.api.externalmessage.ExternalMessageCriteria; +import de.symeda.sormas.api.externalmessage.ExternalMessageStatus; import de.symeda.sormas.api.externalmessage.ExternalMessageType; import de.symeda.sormas.api.sample.SampleReferenceDto; import de.symeda.sormas.api.user.UserRight; @@ -108,6 +97,10 @@ public Predicate createDefaultFilter(CriteriaBuilder cb, From> getUuidsByReportIds(Collection reportIds) { + if (reportIds == null || reportIds.isEmpty()) { + return Collections.emptyMap(); + } + CriteriaBuilder cb = em.getCriteriaBuilder(); + CriteriaQuery cq = cb.createTupleQuery(); + Root root = cq.from(ExternalMessage.class); + cq.multiselect(root.get(AbstractDomainObject.UUID), root.get(ExternalMessage.REPORT_ID), root.get(ExternalMessage.STATUS)); + cq.where(root.get(ExternalMessage.REPORT_ID).in(reportIds)); + return em.createQuery(cq) + .getResultList() + .stream() + .collect( + Collectors.toMap( + tuple -> tuple.get(1, String.class), + tuple -> new de.symeda.sormas.api.utils.Tuple<>(tuple.get(2, ExternalMessageStatus.class), tuple.get(0, String.class)), + (a, b) -> a)); + } } diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/externalmessage/survey/AutomaticSurveyResponseProcessor.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/externalmessage/survey/AutomaticSurveyResponseProcessor.java new file mode 100644 index 00000000000..f6df2a5e71d --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/externalmessage/survey/AutomaticSurveyResponseProcessor.java @@ -0,0 +1,170 @@ +package de.symeda.sormas.backend.externalmessage.survey; + +import java.util.*; +import java.util.concurrent.ExecutionException; +import java.util.stream.Collectors; + +import javax.ejb.EJB; +import javax.enterprise.context.ApplicationScoped; +import javax.inject.Inject; +import javax.transaction.Transactional; + +import de.symeda.sormas.backend.survey.SurveyFacadeEjb; +import de.symeda.sormas.backend.survey.SurveyTokenFacadeEjb; +import org.apache.commons.collections4.CollectionUtils; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import de.symeda.sormas.api.externalmessage.ExternalMessageDto; +import de.symeda.sormas.api.externalmessage.ExternalMessageStatus; +import de.symeda.sormas.api.externalmessage.survey.ExternalMessageSurveyResponseRequest; +import de.symeda.sormas.api.externalmessage.survey.ExternalMessageSurveyResponseResult; +import de.symeda.sormas.api.externalmessage.survey.ExternalMessageSurveyResponseWrapper; +import de.symeda.sormas.api.patch.CaseDataPatchRequest; +import de.symeda.sormas.api.patch.DataPatchResponse; +import de.symeda.sormas.api.patch.DataPatcher; +import de.symeda.sormas.api.survey.SurveyFacade; +import de.symeda.sormas.api.survey.SurveyReferenceDto; +import de.symeda.sormas.api.survey.SurveyTokenDto; +import de.symeda.sormas.api.survey.SurveyTokenFacade; +import de.symeda.sormas.api.utils.Tuple; +import de.symeda.sormas.api.utils.dataprocessing.ProcessingResultStatus; +import de.symeda.sormas.backend.util.CollectorUtils; + +/** + * Performs the coordinating for patch operations out of Survey-responses. + */ +@ApplicationScoped +public class AutomaticSurveyResponseProcessor { + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + @Inject + private DataPatcher dataPatcher; + + @EJB + private SurveyFacadeEjb.SurveyFacadeEjbLocal surveyFacade; + + @EJB + private SurveyTokenFacadeEjb.SurveyTokenFacadeEjbLocal surveyTokenFacade; + + public AutomaticSurveyResponseProcessor() { + } + + public AutomaticSurveyResponseProcessor( + DataPatcher dataPatcher, + SurveyFacadeEjb.SurveyFacadeEjbLocal surveyFacade, + SurveyTokenFacadeEjb.SurveyTokenFacadeEjbLocal surveyTokenFacade) { + this.dataPatcher = dataPatcher; + this.surveyFacade = surveyFacade; + this.surveyTokenFacade = surveyTokenFacade; + } + + @Transactional(Transactional.TxType.REQUIRES_NEW) + public List processSurveyResponses(List externalMessages) + throws InterruptedException, ExecutionException { + + if (CollectionUtils.isEmpty(externalMessages)) { + logger.info("processSurveyResponses: no external messages, nothing to process"); + return Collections.emptyList(); + } + + Map tokenByExternalSurveyIdDictionary = + externalMessages.stream().map(ExternalMessageDto::getSurveyResponseData).map(responseData -> { + ExternalMessageSurveyResponseRequest request = responseData.getLatest().getRequest(); + return new Tuple<>(request.getExternalSurveyId(), request.getToken()); + }).collect(CollectorUtils.toOrderedNullSafeMap(Tuple::getFirst, Tuple::getSecond)); + + List externalSurveyIds = new ArrayList<>(tokenByExternalSurveyIdDictionary.keySet()); + + List> tokenBySurveyReferenceTuples = surveyFacade.getByExternalIds(externalSurveyIds) + .stream() + .map(survey -> new Tuple<>(survey.toReference(), tokenByExternalSurveyIdDictionary.get(survey.getExternalId()))) + .collect(Collectors.toList()); + + List surveyTokens = surveyTokenFacade.getBySurveyReferenceTokenTuples(tokenBySurveyReferenceTuples); + + return externalMessages.stream() + .map((ExternalMessageDto externalMessage) -> tryProcessExternalMessage(externalMessage, surveyTokens)) + .collect(Collectors.toList()); + } + + private @NotNull SurveyResponseProcessingResult tryProcessExternalMessage(ExternalMessageDto externalMessage, List surveyTokens) { + logger.trace("tryProcessExternalMessage: [{}], [{}]", externalMessage, surveyTokens); + SurveyResponseProcessingResult surveyResponseProcessingResult = new SurveyResponseProcessingResult().setExternalMessage(externalMessage); + + ExternalMessageSurveyResponseWrapper latestResponseWrapper = externalMessage.getSurveyResponseData().getLatest(); + ExternalMessageSurveyResponseRequest request = latestResponseWrapper.getRequest(); + + if (latestResponseWrapper.getResult() != null && request.isSkipIfAlreadyProcessed()) { + logger.info( + "Skipping survey response for external message [{}]: already processed and skipIfAlreadyProcessed=true", + externalMessage.getUuid()); + return surveyResponseProcessingResult.setResultStatus(ProcessingResultStatus.CANCELED); + } + + String requestToken = request.getToken(); + Optional surveyToken = + surveyTokens.stream().filter(tokenCandidate -> tokenCandidate.getToken().equals(requestToken)).findAny(); + + if (surveyToken.isEmpty()) { + logger.error("Token could not be found within available survey token DTOs: [{}]. Survey response processing is cancelled.", requestToken); + return surveyResponseProcessingResult.setResultStatus(ProcessingResultStatus.CANCELED); + } + + try { + SurveyTokenDto surveyTokenDto = surveyToken.orElseThrow(); + surveyTokenDto.setResponseReceived(true); + surveyTokenDto.setResponseReceivedDate(request.getResponseReceivedDate()); + surveyTokenDto.setExternalRespondentId(request.getExternalRespondentId()); + + surveyTokenFacade.save(surveyTokenDto); + + CaseDataPatchRequest dataPatchRequest = from(request, surveyTokenDto); + + DataPatchResponse response = dataPatcher.patch(dataPatchRequest); + logger.debug("Patch: request: [{}], response: [{}]", request, response); + + latestResponseWrapper + .setResult(new ExternalMessageSurveyResponseResult().setPatchResponse(response).setCaseUuid(dataPatchRequest.getCaseUuid())); + + if (!response.isApplied()) { + return surveyResponseProcessingResult.setResultStatus(ProcessingResultStatus.CANCELED); + } + + externalMessage.setStatus(ExternalMessageStatus.PROCESSED); + + SurveyResponseProcessingResult result = surveyResponseProcessingResult.setResultStatus(ProcessingResultStatus.DONE); + + logger.trace("result: [{}]", result); + + return result; + + } catch (RuntimeException e) { + logger.error( + "Exception while patching survey response for external message: [{}]. Processing will continue for other messages", + externalMessage.getUuid(), + e); + surveyResponseProcessingResult.setRuntimeException(e); + } + + logger.trace("result: [{}]", surveyResponseProcessingResult); + return surveyResponseProcessingResult; + } + + private static @NotNull CaseDataPatchRequest from(ExternalMessageSurveyResponseRequest request, SurveyTokenDto surveyTokenDto) { + CaseDataPatchRequest caseDataPatchRequest = new CaseDataPatchRequest(); + + caseDataPatchRequest.setEmptyValueBehavior(request.getEmptyValueBehavior()); + caseDataPatchRequest.setReplacementStrategy(request.getReplacementStrategy()); + caseDataPatchRequest.setPatchedInCaseOfFailures(request.isPatchedInCaseOfFailures()); + caseDataPatchRequest.setOrigin(request.getOrigin()); + caseDataPatchRequest.setInputLanguages(request.getInputLanguages()); + caseDataPatchRequest.setCaseUuid(surveyTokenDto.getCaseAssignedTo().getUuid()); + caseDataPatchRequest.setPatchDictionary(request.getPatchDictionary()); + caseDataPatchRequest.setAllowFallbackValues(request.isAllowFallbackValues()); + + return caseDataPatchRequest; + } +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/externalmessage/survey/SurveyResponseProcessingResult.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/externalmessage/survey/SurveyResponseProcessingResult.java new file mode 100644 index 00000000000..007e2bc1dd0 --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/externalmessage/survey/SurveyResponseProcessingResult.java @@ -0,0 +1,65 @@ +package de.symeda.sormas.backend.externalmessage.survey; + +import java.util.Objects; + +import de.symeda.sormas.api.externalmessage.ExternalMessageDto; +import de.symeda.sormas.api.utils.dataprocessing.ProcessingResultStatus; + +/** + * Wrapper object once a SurveyResponse external message was processed. + * Exceptions are caught to be able to continue with the next message from the list. + */ +public class SurveyResponseProcessingResult { + + private ExternalMessageDto externalMessage; + private ProcessingResultStatus resultStatus; + private RuntimeException runtimeException; + + public ExternalMessageDto getExternalMessage() { + return externalMessage; + } + + public SurveyResponseProcessingResult setExternalMessage(ExternalMessageDto externalMessage) { + this.externalMessage = externalMessage; + return this; + } + + public ProcessingResultStatus getResultStatus() { + return resultStatus; + } + + public SurveyResponseProcessingResult setResultStatus(ProcessingResultStatus resultStatus) { + this.resultStatus = resultStatus; + return this; + } + + public RuntimeException getRuntimeException() { + return runtimeException; + } + + public SurveyResponseProcessingResult setRuntimeException(RuntimeException runtimeException) { + this.runtimeException = runtimeException; + return this; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) + return false; + SurveyResponseProcessingResult that = (SurveyResponseProcessingResult) o; + return Objects.equals(externalMessage, that.externalMessage) + && Objects.equals(resultStatus, that.resultStatus) + && Objects.equals(runtimeException, that.runtimeException); + } + + @Override + public int hashCode() { + return Objects.hash(externalMessage, resultStatus, runtimeException); + } + + @Override + public String toString() { + return "SurveyResponseProcessingResultWrapper{" + "externalMessage=" + externalMessage + ", result=" + resultStatus + ", runtimeException=" + + runtimeException + '}'; + } +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/hospitalization/Hospitalization.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/hospitalization/Hospitalization.java index 497aa8a12b8..95cfa8b10da 100644 --- a/sormas-backend/src/main/java/de/symeda/sormas/backend/hospitalization/Hospitalization.java +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/hospitalization/Hospitalization.java @@ -147,7 +147,7 @@ public void setAdmittedToHealthFacility(YesNoUnknown admittedToHealthFacility) { /** * This change date has to be set whenever one of the embedded lists is modified: !oldList.equals(newList) - * + * * @return */ public Date getChangeDateOfEmbeddedLists() { diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/importexport/ImportFacadeEjb.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/importexport/ImportFacadeEjb.java index 89d291686a0..ef83c0fe66a 100644 --- a/sormas-backend/src/main/java/de/symeda/sormas/backend/importexport/ImportFacadeEjb.java +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/importexport/ImportFacadeEjb.java @@ -569,13 +569,15 @@ public void generateSurveyTokenImportTemplateFile(List SurveyTokenDto.GENERATED_DOCUMENT, SurveyTokenDto.RECIPIENT_EMAIL, SurveyTokenDto.RESPONSE_RECEIVED, + SurveyTokenDto.EXTERNAL_RESPONDENT_ID, SurveyTokenDto.RESPONSE_RECEIVED_DATE); writeTemplate(Paths.get(getSurveyTokenImportTemplateFilePath()), importColumns, false); } @Override - public void generateSurveyTokenResponsesImportTemplateFile(List featureConfigurations) throws IOException, NoSuchFieldException { + public void generateSurveyTokenResponsesImportTemplateFile(List featureConfigurations) + throws IOException, NoSuchFieldException { createExportDirectoryIfNecessary(); @@ -583,16 +585,16 @@ public void generateSurveyTokenResponsesImportTemplateFile(List importColumns = new ArrayList<>(); appendListOfFields( - importColumns, - SurveyTokenDto.class, - "", - separator, - featureConfigurations, - SurveyTokenDto.SURVEY, - SurveyTokenDto.ASSIGNMENT_DATE, - SurveyTokenDto.CASE_ASSIGNED_TO, - SurveyTokenDto.GENERATED_DOCUMENT, - SurveyTokenDto.RECIPIENT_EMAIL); + importColumns, + SurveyTokenDto.class, + "", + separator, + featureConfigurations, + SurveyTokenDto.SURVEY, + SurveyTokenDto.ASSIGNMENT_DATE, + SurveyTokenDto.CASE_ASSIGNED_TO, + SurveyTokenDto.GENERATED_DOCUMENT, + SurveyTokenDto.RECIPIENT_EMAIL); writeTemplate(Paths.get(getSurveyTokenResponsesImportTemplateFilePath()), importColumns, false); diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/json/ObjectMapperProvider.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/json/ObjectMapperProvider.java new file mode 100644 index 00000000000..86c2fd4bff4 --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/json/ObjectMapperProvider.java @@ -0,0 +1,79 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2020 Helmholtz-Zentrum für Infektionsforschung GmbH (HZI) + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU 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 General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.symeda.sormas.backend.json; + +import javax.annotation.Nullable; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +/** + * Permits unified access to a pre-configured {@link ObjectMapper} instance. + */ +public final class ObjectMapperProvider { + + private static final Logger logger = LoggerFactory.getLogger(ObjectMapperProvider.class); + + private static volatile ObjectMapper instance; + + private ObjectMapperProvider() { + if (instance != null) { + throw new IllegalStateException("ObjectMapper instance already created"); + } + } + + public static ObjectMapper getInstance() { + if (instance == null) { + synchronized (ObjectMapperProvider.class) { + if (instance == null) { + instance = createObjectMapper(); + } + } + } + return instance; + } + + /** + * Produces JSON or swallows error and return null, meant for logging purposes, were null as fallback is not business-critical. + * + * @param object + * to be serialized as JSON + * @return JSON representation or null if exception. + */ + @Nullable + public static String writeValueAsStringFailSafe(Object object) { + try { + return getInstance().writeValueAsString(object); + } catch (JsonProcessingException e) { + logger.error("Couldn't write JSON of: [{}] of type: [{}]", object, object.getClass(), e); + return null; + } + } + + private static ObjectMapper createObjectMapper() { + ObjectMapper mapper = new ObjectMapper(); + + mapper.registerModule(new JavaTimeModule()); + + mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + return mapper; + } +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/AttachedEntityWrapper.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/AttachedEntityWrapper.java new file mode 100644 index 00000000000..dbd684c0f17 --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/AttachedEntityWrapper.java @@ -0,0 +1,67 @@ +package de.symeda.sormas.backend.patch; + +import java.util.Objects; + +import javax.validation.constraints.NotNull; + +import de.symeda.sormas.api.EntityDto; + +/** + * It's required to know if the DTO was already "attached" (known in Persistence Context / EntityManager). + */ +public class AttachedEntityWrapper { + + @NotNull + private EntityDto entityDto; + + /** + * True if already persisted: false otherwise to indicate the entity must be merged. + */ + private boolean attached = true; + + public static AttachedEntityWrapper attached(EntityDto entityDto) { + AttachedEntityWrapper attachedEntityWrapper = new AttachedEntityWrapper(); + attachedEntityWrapper.setAttached(true); + attachedEntityWrapper.setEntityDto(entityDto); + return attachedEntityWrapper; + } + + public static AttachedEntityWrapper notYetAttached(EntityDto entityDto) { + AttachedEntityWrapper attachedEntityWrapper = new AttachedEntityWrapper(); + attachedEntityWrapper.setAttached(false); + attachedEntityWrapper.setEntityDto(entityDto); + return attachedEntityWrapper; + } + + @NotNull + public EntityDto getEntityDto() { + return entityDto; + } + + public AttachedEntityWrapper setEntityDto(EntityDto entityDto) { + this.entityDto = entityDto; + return this; + } + + public boolean isAttached() { + return attached; + } + + public AttachedEntityWrapper setAttached(boolean attached) { + this.attached = attached; + return this; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) + return false; + AttachedEntityWrapper that = (AttachedEntityWrapper) o; + return attached == that.attached && Objects.equals(entityDto, that.entityDto); + } + + @Override + public int hashCode() { + return Objects.hash(entityDto, attached); + } +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/BusinessDtoFacade.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/BusinessDtoFacade.java new file mode 100644 index 00000000000..aaa94567c33 --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/BusinessDtoFacade.java @@ -0,0 +1,316 @@ +package de.symeda.sormas.backend.patch; + +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; + +import javax.annotation.Nullable; +import javax.annotation.PostConstruct; +import javax.ejb.EJB; +import javax.enterprise.context.ApplicationScoped; +import javax.validation.constraints.NotNull; + +import de.symeda.sormas.api.EntityDto; +import de.symeda.sormas.api.activityascase.ActivityAsCaseDto; +import de.symeda.sormas.api.activityascase.ActivityAsCaseType; +import de.symeda.sormas.api.caze.CaseDataDto; +import de.symeda.sormas.api.exposure.ExposureDto; +import de.symeda.sormas.api.exposure.ExposureType; +import de.symeda.sormas.api.hospitalization.PreviousHospitalizationDto; +import de.symeda.sormas.api.immunization.ImmunizationDto; +import de.symeda.sormas.api.person.PersonDto; +import de.symeda.sormas.api.vaccination.VaccinationDto; +import de.symeda.sormas.backend.caze.CaseFacadeEjb; +import de.symeda.sormas.backend.immunization.ImmunizationFacadeEjb; +import de.symeda.sormas.backend.person.PersonFacadeEjb; +import de.symeda.sormas.backend.user.UserFacadeEjb; + +/** + * Meant as single entry point for usages were multiple Business DTOs must be fetched or saved. + */ +@ApplicationScoped +public class BusinessDtoFacade { + + @EJB + private CaseFacadeEjb.CaseFacadeEjbLocal caseFacade; + + @EJB + private PersonFacadeEjb.PersonFacadeEjbLocal personFacade; + + @EJB + private ImmunizationFacadeEjb.ImmunizationFacadeEjbLocal immunizationFacade; + + @EJB + private UserFacadeEjb.UserFacadeEjbLocal userFacade; + + private final Map, Function> directDtoSaveDictionary = new HashMap<>(); + + private final Map, Function> dtoRetrieverDictionary = new HashMap<>(); + + private final Map>> dtoRetrieverByI18nDictionaryRead = new HashMap<>(); + + private final Map> dtoRetrieverByI18nDictionaryCreateUpdate = new HashMap<>(); + + /** + * Some {@link EntityDto} must be attached to a "parent" to be saved. + */ + private final Map, LeafAttacher> leafAttacherRegistry = new LinkedHashMap<>(); + + @PostConstruct + private void init() { + registerDirectSaveOperations(); + registerFetchOperations(); + + registerFetchByI18nOperationsRead(); + + registerFetchByI18nOperationsCreateUpdate(); + + registerLeafAttacherOperations(); + } + + private void registerDirectSaveOperations() { + registerSave(CaseDataDto.class, caseDataDto -> caseFacade.save(caseDataDto)); + registerSave(PersonDto.class, personDto -> personFacade.save(personDto)); + registerSave(ImmunizationDto.class, immunizationDto -> immunizationFacade.save(immunizationDto)); + } + + private void registerSave(Class dtoClass, Function consumer) { + directDtoSaveDictionary.put(dtoClass, consumer); + } + + private void registerFetchOperations() { + registerFetch(PersonDto.class, caseDataDto -> personFacade.getByUuid(caseDataDto.getPerson().getUuid())); + } + + private void registerFetch(Class dtoClass, Function fct) { + dtoRetrieverDictionary.put(dtoClass, fct); + } + + private void registerFetchByI18nOperationsRead() { + registerFetchByI18nRead( + PersonDto.I18N_PREFIX, + caseDataDto -> Collections.singletonList(personFacade.getByUuid(caseDataDto.getPerson().getUuid()))); + registerFetchByI18nRead( + ImmunizationDto.I18N_PREFIX, + caseDataDto -> immunizationFacade.getByPersonUuids(Collections.singletonList(caseDataDto.getPerson().getUuid()))); + + registerFetchByI18nRead( + VaccinationDto.I18N_PREFIX, + caseDataDto -> immunizationFacade.getByPersonUuids(Collections.singletonList(caseDataDto.getPerson().getUuid())) + .stream() + .flatMap(immunization -> immunization.getVaccinations().stream()) + .collect(Collectors.toList())); + + registerFetchByI18nRead(ExposureDto.I18N_PREFIX, caseDataDto -> caseDataDto.getEpiData().getExposures()); + + registerFetchByI18nRead(ActivityAsCaseDto.I18N_PREFIX, caseDataDto -> caseDataDto.getEpiData().getActivitiesAsCase()); + + registerFetchByI18nRead( + PreviousHospitalizationDto.I18N_PREFIX, + caseDataDto -> caseDataDto.getHospitalization().getPreviousHospitalizations()); + } + + private void registerFetchByI18nOperationsCreateUpdate() { + registerFetchByI18nCreateUpdate( + PersonDto.I18N_PREFIX, + caseDataDto -> AttachedEntityWrapper.attached(personFacade.getByUuid(caseDataDto.getPerson().getUuid()))); + + registerFetchByI18nCreateUpdate( + ImmunizationDto.I18N_PREFIX, + createImmunizationDtoFromCaseFct().andThen(AttachedEntityWrapper::notYetAttached)); + + registerFetchByI18nCreateUpdate( + VaccinationDto.I18N_PREFIX, + caseDataDto -> AttachedEntityWrapper.notYetAttached(VaccinationDto.build(userFacade.getCurrentUserAsReference()))); + + registerFetchByI18nCreateUpdate( + ExposureDto.I18N_PREFIX, + caseDataDto -> AttachedEntityWrapper.notYetAttached(ExposureDto.build(ExposureType.UNKNOWN))); + + registerFetchByI18nCreateUpdate( + ActivityAsCaseDto.I18N_PREFIX, + caseDataDto -> AttachedEntityWrapper.notYetAttached(ActivityAsCaseDto.build(ActivityAsCaseType.UNKNOWN))); + + registerFetchByI18nCreateUpdate( + PreviousHospitalizationDto.I18N_PREFIX, + caze -> AttachedEntityWrapper.notYetAttached(PreviousHospitalizationDto.build(caze))); + } + + private Function createImmunizationDtoFromCaseFct() { + return caseDataDto -> { + ImmunizationDto immunization = ImmunizationDto.build(caseDataDto.getPerson()); + + immunization.setRelatedCase(caseDataDto.toReference()); + immunization.setPerson(caseDataDto.getPerson()); + immunization.setDisease(caseDataDto.getDisease()); + immunization.setResponsibleRegion(caseDataDto.getResponsibleRegion()); + immunization.setResponsibleDistrict(caseDataDto.getResponsibleDistrict()); + immunization.setReportingUser(userFacade.getCurrentUserAsReference()); + + return immunization; + }; + } + + private void registerFetchByI18nRead(String i18nName, Function> fct) { + dtoRetrieverByI18nDictionaryRead.put(i18nName, fct); + } + + private void registerFetchByI18nCreateUpdate(String i18nName, Function fct) { + dtoRetrieverByI18nDictionaryCreateUpdate.put(i18nName, fct); + } + + private void registerLeafAttacherOperations() { + registerLeafAttacher(VaccinationDto.class, (leaf, list) -> { + ImmunizationDto immunization = fetchType(list, ImmunizationDto.class) + .orElseGet(() -> (ImmunizationDto) createImmunizationDtoFromCaseFct().apply(requireCaseData(list))); + immunization.getVaccinations().add((VaccinationDto) leaf); + return immunization; + }); + registerLeafAttacher(ExposureDto.class, (leaf, list) -> { + CaseDataDto caseData = requireCaseData(list); + caseData.getEpiData().getExposures().add((ExposureDto) leaf); + return caseData; + }); + registerLeafAttacher(ActivityAsCaseDto.class, (leaf, list) -> { + CaseDataDto caseData = requireCaseData(list); + caseData.getEpiData().getActivitiesAsCase().add((ActivityAsCaseDto) leaf); + return caseData; + }); + registerLeafAttacher(PreviousHospitalizationDto.class, (leaf, list) -> { + CaseDataDto caseData = requireCaseData(list); + caseData.getHospitalization().getPreviousHospitalizations().add((PreviousHospitalizationDto) leaf); + return caseData; + }); + } + + private void registerLeafAttacher(Class leafClass, LeafAttacher attacher) { + leafAttacherRegistry.put(leafClass, attacher); + } + + private CaseDataDto requireCaseData(List dtosInProgress) { + return fetchType(dtosInProgress, CaseDataDto.class).orElseThrow( + () -> new IllegalStateException( + String.format("When saving child leaf entities the caseData must be present, but was not: [%s]", dtosInProgress))); + } + + @Nullable + public CaseDataDto getCaseDataDtoNullable(String caseUuid) { + return caseFacade.getByUuid(caseUuid); + } + + @NotNull + public CaseDataDto getCaseDataDto(String caseUuid) { + return Optional.ofNullable(getCaseDataDtoNullable(caseUuid)) + .orElseThrow(() -> new IllegalStateException(String.format("No CaseDataDto found for [%s]", caseUuid))); + } + + /** + * Meant for creational purposes. + * + * @param entityClass + * target class + * @param caseDataDto + * linked case + * @return DTO if found. + * @param + * types + */ + @Nullable + public T fetch(@NotNull Class entityClass, CaseDataDto caseDataDto) { + return Optional.ofNullable((Function) dtoRetrieverDictionary.get(entityClass)) + .orElseThrow(() -> new IllegalStateException(String.format("No fetch function defined for: [%s]", entityClass))) + .apply(caseDataDto); + } + + /** + * Meant for display purposes. + * + * @param i18nName + * DtoPrefix per example {@link PersonDto#I18N_PREFIX}. + * @param caseDataDto + * linked case. + * @return entity dto if found. + */ + @Nullable + public List fetchByI18nNameForDisplay(@NotNull String i18nName, CaseDataDto caseDataDto) { + return Optional.ofNullable(dtoRetrieverByI18nDictionaryRead.get(i18nName)) + .orElseThrow(() -> new IllegalStateException(String.format("No fetch function defined for: [%s]", i18nName))) + .apply(caseDataDto); + } + + /** + * Warning: Will retrieve a new instance on every fetch, MUST be cached within a single patch operation. + * + * @param i18nName + * I18N translation key + * @param caseDataDto + * root/reference entity + * @return "un-attached" DTO that will be thrown away if not saved. + */ + public Optional tryFetchByI18nNameForCreateUpdate(@NotNull String i18nName, CaseDataDto caseDataDto) { + return Optional.ofNullable(dtoRetrieverByI18nDictionaryCreateUpdate.get(i18nName)).map(fct -> fct.apply(caseDataDto)); + } + + /** + * @return I18n prefixes registered for display retrieval. + */ + public Set fetchablePrefixes() { + return dtoRetrieverByI18nDictionaryRead.keySet(); + } + + /** + * @return I18n prefixes registered for create/update retrieval. + */ + public Set createUpdatePrefixes() { + return Collections.unmodifiableSet(dtoRetrieverByI18nDictionaryCreateUpdate.keySet()); + } + + /** + * @return DTO classes that have a direct save function registered. + */ + public Set> savableDtoClasses() { + return Collections.unmodifiableSet(directDtoSaveDictionary.keySet()); + } + + /** + * Single entry for saving a business DTO. + * + * @param entityDto + * business DTO + * @return saved DTO. + * @param + * type + */ + private T saveDirectEntity(@NotNull EntityDto entityDto) { + Class entityDtoClass = entityDto.getClass(); + + return Optional.ofNullable((Function) directDtoSaveDictionary.get(entityDtoClass)) + .orElseThrow(() -> new IllegalStateException(String.format("No save function defined for: [%s]", entityDtoClass))) + .apply((T) entityDto); + } + + public void save(@NotNull List entityDtos) { + ArrayList dtosToSave = new ArrayList<>(entityDtos); + + leafAttacherRegistry.forEach((leafClass, attacher) -> dtosToSave.stream().filter(leafClass::isInstance).findAny().ifPresent(leaf -> { + EntityDto parent = attacher.attachAndReturnParent(leaf, dtosToSave); + dtosToSave.remove(leaf); + if (!dtosToSave.contains(parent)) { + dtosToSave.add(parent); + } + })); + + dtosToSave.forEach(this::saveDirectEntity); + } + + private static @NotNull Optional fetchType(List entityDtos, Class targetClass) { + return entityDtos.stream().filter(targetClass::isInstance).map(targetClass::cast).findAny(); + } + + @FunctionalInterface + private interface LeafAttacher { + + EntityDto attachAndReturnParent(EntityDto leaf, List dtosInProgress); + } + +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/DataPatcherImpl.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/DataPatcherImpl.java new file mode 100644 index 00000000000..266c7534a8b --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/DataPatcherImpl.java @@ -0,0 +1,400 @@ +package de.symeda.sormas.backend.patch; + +import java.util.*; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.function.Supplier; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import javax.ejb.EJB; +import javax.enterprise.context.ApplicationScoped; +import javax.inject.Inject; + +import org.apache.commons.lang3.StringUtils; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import de.symeda.sormas.api.Disease; +import de.symeda.sormas.api.EntityDto; +import de.symeda.sormas.api.caze.CaseDataDto; +import de.symeda.sormas.api.patch.*; +import de.symeda.sormas.api.patch.mapping.FieldCustomMapper; +import de.symeda.sormas.api.patch.mapping.FieldPatchRequest; +import de.symeda.sormas.api.patch.mapping.ValueMappingResult; +import de.symeda.sormas.api.patch.mapping.ValuePatchRequest; +import de.symeda.sormas.api.utils.Tuple; +import de.symeda.sormas.api.utils.fieldvisibility.FieldVisibilityCheckers; +import de.symeda.sormas.backend.common.ConfigFacadeEjb; +import de.symeda.sormas.backend.feature.FeatureConfigurationFacadeEjb; +import de.symeda.sormas.backend.json.ObjectMapperProvider; +import de.symeda.sormas.backend.patch.mapping.FieldCustomMapperRegistry; +import de.symeda.sormas.backend.patch.mapping.PatchEqualityCheckersRegistry; +import de.symeda.sormas.backend.patch.mapping.ValueMapperRegistry; +import de.symeda.sormas.backend.util.CollectorUtils; + +@ApplicationScoped +public class DataPatcherImpl implements DataPatcher { + + private final static Logger logger = LoggerFactory.getLogger(DataPatcherImpl.class); + + @Inject + private PatchFieldHelper patchFieldHelper; + + @Inject + private ValueMapperRegistry valueMapperRegistry; + + @Inject + private FieldCustomMapperRegistry fieldCustomMapperRegistry; + + @Inject + private PatchEqualityCheckersRegistry patchEqualityCheckersRegistry; + + @Inject + private BusinessDtoFacade businessDtoFacade; + + @EJB + private FeatureConfigurationFacadeEjb.FeatureConfigurationFacadeEjbLocal featureConfigurationFacade; + + @EJB + private ConfigFacadeEjb.ConfigFacadeEjbLocal configFacade; + + public DataPatcherImpl() { + } + + public DataPatcherImpl( + PatchFieldHelper patchFieldHelper, + ValueMapperRegistry valueMapperRegistry, + FieldCustomMapperRegistry fieldCustomMapperRegistry, + PatchEqualityCheckersRegistry patchEqualityCheckersRegistry, + BusinessDtoFacade businessDtoFacade, + FeatureConfigurationFacadeEjb.FeatureConfigurationFacadeEjbLocal featureConfigurationFacade, + ConfigFacadeEjb.ConfigFacadeEjbLocal configFacade) { + + this.patchFieldHelper = patchFieldHelper; + this.valueMapperRegistry = valueMapperRegistry; + this.fieldCustomMapperRegistry = fieldCustomMapperRegistry; + this.patchEqualityCheckersRegistry = patchEqualityCheckersRegistry; + this.businessDtoFacade = businessDtoFacade; + this.featureConfigurationFacade = featureConfigurationFacade; + this.configFacade = configFacade; + } + + @Override + public DataPatchResponse patch(CaseDataPatchRequest request) { + logger.debug("patch: [{}]", request); + + CaseDataDto caseData = getCaseDataDto(request); + + Disease disease = caseData.getDisease(); + + Map entityCache = new HashMap<>(); + entityCache.put(CaseDataDto.I18N_PREFIX, AttachedEntityWrapper.attached(caseData)); + + List patchingTuples = computePatchingTuples(request); + + List results = patchingTuples.stream().map(singleFieldPatchResult -> { + String fullFieldName = singleFieldPatchResult.path; + SinglePatchResult singlePatchResult = new SinglePatchResult().setFieldName(fullFieldName); + + Supplier target = () -> findAppropriateTarget(fullFieldName, caseData, entityCache); + + try { + return produceSinglePatchResult(request, singleFieldPatchResult, disease, target); + } catch (RuntimeException e) { + logger.error("Failure during patch operation for request: [{}], [{}]", request, singleFieldPatchResult, e); + return singlePatchResult.setFailure(new DataPatchFailure().setDataPatchFailureCause(DataPatchFailureCause.TECHNICAL)); + } + + }).collect(Collectors.toList()); + + Map validPatchDictionary = buildDictionaryFor(results, SinglePatchResult::getValue, true); + DataPatchResponse response = new DataPatchResponse().setApplied(false) + .setFailures(buildDictionaryFor(results, SinglePatchResult::getFailure, false)) + .setValidPatchDictionary(validPatchDictionary); + + if (validPatchDictionary.isEmpty() || (!request.isPatchedInCaseOfFailures() && response.hasFailures())) { + logger.info( + "No patch was applied as contained failures AND request doesn't allow patch in case of failures: request: [{}], response: [{}]", + request, + response); + return response; + } + + saveDTOsIfAppropriate(entityCache); + + logger.debug("dataPatchResponse: [{}]", response); + + return response.setApplied(true); + } + + @NotNull + private SinglePatchResult produceSinglePatchResult( + CaseDataPatchRequest request, + SingleFieldPatchResult singleFieldPatchResult, + Disease disease, + Supplier target) { + + return invalidFieldResult(singleFieldPatchResult).or(() -> fieldMappingResult(singleFieldPatchResult, disease, request, target)) + .orElseGet(() -> valueMappingResult(singleFieldPatchResult, disease, request, target)); + } + + private void saveDTOsIfAppropriate(Map entityCache) { + List toSave = new ArrayList<>(entityCache.values().stream().map(AttachedEntityWrapper::getEntityDto).collect(Collectors.toList())); + + if (toSave.isEmpty()) { + logger.warn("Nothing to save in entity cache"); + return; + } + + toSave.forEach(entity -> { + logger.info("{} was modified, will be saved. Enable debug to see fully patched object", entity.getClass().getSimpleName()); + if (logger.isDebugEnabled()) { + logger.debug("{}: \n{}", entity.getClass().getSimpleName(), ObjectMapperProvider.writeValueAsStringFailSafe(entity)); + } + }); + + businessDtoFacade.save(toSave); + } + + private @NotNull Map buildDictionaryFor( + List results, + Function fct, + boolean valueContext) { + return results.stream() + // edge case were target value is null: this is allowed, which makes both fields null. + .filter( + singlePatchResult -> fct.apply(singlePatchResult) != null + || (valueContext && singlePatchResult.getFailure() == null && singlePatchResult.getValue() == null)) + .collect(CollectorUtils.toNullSafeMap(SinglePatchResult::getFieldName, fct)); + } + + private @NotNull SinglePatchResult valueMappingResult( + SingleFieldPatchResult singleFieldPatchResult, + Disease disease, + CaseDataPatchRequest request, + Supplier targetOpt) { + + String fullFieldName = singleFieldPatchResult.path; + + SinglePatchResult singlePatchResult = new SinglePatchResult().setFieldName(fullFieldName); + + AttachedEntityWrapper attachedEntityWrapper = targetOpt.get(); + Object target = attachedEntityWrapper.getEntityDto(); + String relativeFieldName = fullFieldName.substring(fullFieldName.indexOf('.') + 1); + Tuple, PropertyAccessFailure> nestedPropertyTypeTuple = + PropertyAccessor.getNestedPropertyType(target, relativeFieldName, getFieldVisibilityCheckers(disease)); + Object untypedTargetValue = singleFieldPatchResult.value; + + PropertyAccessFailure propertyAccessFailure = nestedPropertyTypeTuple.getSecond(); + if (propertyAccessFailure != null) { + logger.info("Missing field: [{}] on target: [{}]", relativeFieldName, target); + return singlePatchResult.setFailure(buildFailure(propertyAccessFailure.getRelatedPatchFailureCause(), untypedTargetValue)); + } + Class targetType = nestedPropertyTypeTuple.getFirst(); + + ValueMappingResult result = valueMapperRegistry.map( + new ValuePatchRequest().setValue(untypedTargetValue) + .setTargetType(targetType) + .setInputLanguages(request.getInputLanguages()) + .setAllowFallbackValues(request.isAllowFallbackValues())); + + DataPatchFailureCause dataPatchFailureCause = result.getDataPatchFailureCause(); + if (dataPatchFailureCause != null) { + return singlePatchResult.setFailure(buildFailure(dataPatchFailureCause, untypedTargetValue)); + } + + Object typedValue = result.getData(); + + if (!attachedEntityWrapper.isAttached() && !StringUtils.contains(relativeFieldName, ".")) { + logger.debug( + "Entity was not yet attached and relative field name: [{}] is not for sub-Objects, therefore overwrite is allowed and ignored for this target only: [{}]", + relativeFieldName, + target.getClass()); + } else if (request.getReplacementStrategy() == DataReplacementStrategy.IF_NOT_ALREADY_PRESENT) { + Optional nestedPropertyValue = PropertyAccessor.getNestedProperty(target, relativeFieldName); + + if (nestedPropertyValue.isPresent()) { + Object currentValue = nestedPropertyValue.orElseThrow(); + + if (!patchEqualityCheckersRegistry.areEqual(currentValue, typedValue)) { + return singlePatchResult.setFailure( + new DataPatchFailure().setDataPatchFailureCause(DataPatchFailureCause.FORBIDDEN_VALUE_OVERRIDE) + .setExistingFieldValue(currentValue) + .setProvidedFieldValue(untypedTargetValue)); + } + } + } + + Optional exception = PropertyAccessor.setNestedProperty(target, relativeFieldName, typedValue); + if (exception.isPresent()) { + Exception e = exception.orElseThrow(); + logger.error("Setting nested property failed for: field [{}] on [{}] with value: [{}]", relativeFieldName, target, typedValue, e); + return singlePatchResult.setFailure( + new DataPatchFailure().setDataPatchFailureCause(DataPatchFailureCause.TECHNICAL).setProvidedFieldValue(untypedTargetValue)); + } else { + return singlePatchResult.setValue(untypedTargetValue); + } + } + + private DataPatchFailure buildFailure(DataPatchFailureCause fieldDoesNotExist, Object untypedTargetValue) { + return new DataPatchFailure().setDataPatchFailureCause(fieldDoesNotExist).setProvidedFieldValue(untypedTargetValue); + } + + private @NotNull Optional invalidFieldResult(SingleFieldPatchResult singleFieldPatchResult) { + return Optional.ofNullable(singleFieldPatchResult.failureCause) + .map(invalidFieldFailureCause -> buildFailureFor(singleFieldPatchResult, invalidFieldFailureCause)); + } + + private Optional fieldMappingResult( + SingleFieldPatchResult singleFieldPatchResult, + Disease disease, + CaseDataPatchRequest request, + Supplier target) { + + String fullFieldName = singleFieldPatchResult.path; + + Optional mapper = fieldCustomMapperRegistry.getMapper(fullFieldName, disease); + + Object untypedTargetValue = singleFieldPatchResult.value; + if (mapper.isPresent()) { + SinglePatchResult singlePatchResult = new SinglePatchResult().setFieldName(fullFieldName); + + Optional dataPatchFailureOpt = mapper.orElseThrow() + .map( + new FieldPatchRequest().setFieldName(fullFieldName) + .setReplacementType(request.getReplacementStrategy()) + .setOrigin(request.getOrigin()) + .setTarget(target.get().getEntityDto()) + .setValue(untypedTargetValue)); + + return dataPatchFailureOpt.map(singlePatchResult::setFailure).or(() -> Optional.of(singlePatchResult.setValue(untypedTargetValue))); + } + + return Optional.empty(); + } + + private SinglePatchResult buildFailureFor(SingleFieldPatchResult singleFieldPatchResult, DataPatchFailureCause fieldFailureCause) { + return new SinglePatchResult().setFieldName(singleFieldPatchResult.path) + .setFailure(buildFailure(fieldFailureCause, singleFieldPatchResult.value)); + } + + private FieldVisibilityCheckers getFieldVisibilityCheckers(Disease disease) { + return FieldVisibilityCheckers.withCountry(configFacade.getCountryLocale()) + .andWithDisease(disease) + .andWithFeatureType(featureConfigurationFacade.getActiveServerFeatureConfigurations()); + } + + private List computePatchingTuples(CaseDataPatchRequest request) { + Predicate> filterPredicate = buildAdequateDictionaryValuePredicate(request); + + return request.getPatchDictionary() + .entrySet() + .stream() + .filter(entry -> StringUtils.isNotBlank(entry.getKey())) + .filter(filterPredicate) + .flatMap(originalEntry -> { + String path = originalEntry.getKey(); + + PathFailureCause pathFailureCause = patchFieldHelper.checkIfPathIsInvalid(path); + + Tuple unAliasedTuple = patchFieldHelper.resolveAlias(path); + Map.Entry entry = toMapEntry(unAliasedTuple.getFirst(), originalEntry.getValue()); + + DataPatchFailureCause dataPatchFailureCause = Optional.ofNullable(pathFailureCause) + .map(PathFailureCause::getRelatedPatchFailureCause) + .or(() -> Optional.ofNullable(unAliasedTuple.getSecond()).map(PathFailureCause::getRelatedPatchFailureCause)) + .orElse(null); + + if (dataPatchFailureCause != null) { + return Stream.of(new SingleFieldPatchResult(entry.getKey(), dataPatchFailureCause, entry.getValue())); + } + + if (!patchFieldHelper.isMultipleFieldFormat(path)) { + return Stream.of(new SingleFieldPatchResult(entry.getKey(), null, entry.getValue())); + } + + return splitMultipleFieldsPath(entry); + }) + .collect(Collectors.toList()); + } + + private AbstractMap.@NotNull SimpleEntry toMapEntry(String first, Object value) { + return new AbstractMap.SimpleEntry<>(first, value); + } + + @NotNull + private Stream splitMultipleFieldsPath(Map.Entry entry) { + return patchFieldHelper.splitMultipleFieldsPath(entry.getKey()) + .map(singlePath -> new SingleFieldPatchResult(singlePath, null, entry.getValue())); + } + + private @NotNull Predicate> buildAdequateDictionaryValuePredicate(CaseDataPatchRequest request) { + return request.getEmptyValueBehavior() == EmptyValueBehavior.REPLACE ? ignored -> true : buildEmptyValuePredicate(); + } + + private @NotNull CaseDataDto getCaseDataDto(CaseDataPatchRequest request) { + String caseUuid = request.getCaseUuid(); + CaseDataDto caseData = businessDtoFacade.getCaseDataDtoNullable(caseUuid); + + if (caseData == null) { + throw new IllegalStateException(String.format("No case found for uuid: [%s]", caseUuid)); + } + + return caseData; + } + + private AttachedEntityWrapper findAppropriateTarget(String resolvedPath, CaseDataDto caseData, Map entityCache) { + String prefix = extractPrefix(resolvedPath); + + if (entityCache.containsKey(prefix)) { + return entityCache.get(prefix); + } + + Optional fetched = businessDtoFacade.tryFetchByI18nNameForCreateUpdate(prefix, caseData); + if (fetched.isPresent()) { + entityCache.put(prefix, fetched.get()); + return fetched.get(); + } + + logger.error("Fallbacked to entity for resolved path: [{}]. This should not occur as CaseData is already in entityCache", resolvedPath); + return AttachedEntityWrapper.attached(caseData); + } + + private String extractPrefix(String fieldName) { + int dotIndex = fieldName.indexOf('.'); + return dotIndex == -1 ? fieldName : fieldName.substring(0, dotIndex); + } + + private Predicate> buildEmptyValuePredicate() { + + return stringObjectEntry -> { + Object value = stringObjectEntry.getValue(); + + if (value == null) { + return false; + } + + if (value instanceof String) { + return !((String) value).trim().isEmpty(); + } + + return true; + }; + } + + private static final class SingleFieldPatchResult { + + final String path; + final DataPatchFailureCause failureCause; + final Object value; + + SingleFieldPatchResult(String fieldPath, DataPatchFailureCause cause, Object value) { + this.path = fieldPath; + this.failureCause = cause; + this.value = value; + } + } + +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/DefaultForbiddenFields.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/DefaultForbiddenFields.java new file mode 100644 index 00000000000..d984ae580b2 --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/DefaultForbiddenFields.java @@ -0,0 +1,57 @@ +package de.symeda.sormas.backend.patch; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +/** + * List it too big to keep directly within {@link PatchFieldHelper}. + */ +public class DefaultForbiddenFields { + + private DefaultForbiddenFields() { + } + + private static final Set DEFAULT_FORBIDDEN_FIELDS = buildDefaultForbiddenFieldsList(); + + private static Set buildDefaultForbiddenFieldsList() { + Set forbiddenFields = new HashSet<>(); + + // TECHNICAL + forbiddenFields.add(".uuid"); + forbiddenFields.add(".creationDate"); + forbiddenFields.add(".changeDate"); + forbiddenFields.add(".pseudonymized"); + forbiddenFields.add(".inJurisdiction"); + + // lifecycle + forbiddenFields.add(".deleted"); + forbiddenFields.add(".archived"); + forbiddenFields.add(".deletionReason"); + forbiddenFields.add(".otherDeletionReason"); + + // users + forbiddenFields.add(".reportingUser"); + forbiddenFields.add(".surveillanceOfficer"); + forbiddenFields.add(".classificationUser"); + forbiddenFields.add(".classifiedBy"); + forbiddenFields.add(".classificationDate"); + + // references + forbiddenFields.add("Immunization.relatedCase"); + forbiddenFields.add("Immunization.person"); + forbiddenFields.add("Vaccination.immunization"); + + // PERSON + forbiddenFields.add("Person.birthdate"); + forbiddenFields.add("Person.birthdateDD"); + forbiddenFields.add("Person.birthdateMM"); + forbiddenFields.add("Person.birthdateYYYY"); + + return Collections.unmodifiableSet(forbiddenFields); + } + + public static Set getDefaultForbiddenFields() { + return DEFAULT_FORBIDDEN_FIELDS; + } +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/PatchFieldHelper.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/PatchFieldHelper.java new file mode 100644 index 00000000000..fb468b04595 --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/PatchFieldHelper.java @@ -0,0 +1,184 @@ +package de.symeda.sormas.backend.patch; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import javax.annotation.Nullable; +import javax.ejb.EJB; +import javax.enterprise.context.ApplicationScoped; +import javax.inject.Inject; +import javax.validation.constraints.NotNull; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import de.symeda.sormas.api.systemconfiguration.SystemConfigurationValueFacade; +import de.symeda.sormas.api.utils.Tuple; +import de.symeda.sormas.backend.patch.alias.PathAliasHelper; + +@ApplicationScoped +public class PatchFieldHelper { + + private final static Logger logger = LoggerFactory.getLogger(PatchFieldHelper.class); + + public static final String PATH_SEPARATOR = "."; + public static final String DUPLICATE_MARKER = "_duplicate_"; + + private static final String OPENING_PARENTHESIS = "("; + private static final String CLOSING_PARENTHESIS = ")"; + private static final String PIPE = "|"; + + public static final String PATCH_FORBIDDEN_FIELDS_CONFIG_KEY = "PATCH_FORBIDDEN_FIELDS"; + + @Inject + private PathAliasHelper pathAliasHelper; + + @EJB + private SystemConfigurationValueFacade systemConfigurationValueFacade; + + @Inject + private BusinessDtoFacade businessDtoFacade; + + public PatchFieldHelper() { + } + + public PatchFieldHelper(PathAliasHelper pathAliasHelper) { + this.pathAliasHelper = pathAliasHelper; + } + + public Set> extractFieldTuples(Set fields, Set additionalSupportedPrefixes) { + return fields.stream().map(path -> Tuple.of(path, checkIfPathIsInvalidImpl(path, additionalSupportedPrefixes))).flatMap(tuple -> { + + String originalPath = tuple.getFirst(); + + if (tuple.getSecond() != null || !isMultipleFieldFormat(originalPath)) { + return Stream.of(tuple); + } + + return splitMultipleFieldsPath(originalPath).map(splitPath -> Tuple.of(splitPath, (PathFailureCause) null)); + + }).collect(Collectors.toSet()); + } + + @NotNull + public Stream splitMultipleFieldsPath(String path) { + int openingParenthesisIndex = path.indexOf("("); + String prefix = path.substring(0, openingParenthesisIndex); + + int closeParen = path.indexOf(')'); + + String restPath = path.substring(openingParenthesisIndex + 1, closeParen); + + return Arrays.stream(restPath.split("\\|")).map(suffix -> prefix + suffix); + } + + @Nullable + public PathFailureCause checkIfPathIsInvalid(String path) { + return checkIfPathIsInvalidImpl(path, Set.of()); + } + + private PathFailureCause checkIfPathIsInvalidImpl(String path, Set additionalSupportedPrefixes) { + PathFailureCause dataPatchFailureCause = null; + + if (!path.contains(PATH_SEPARATOR)) { + dataPatchFailureCause = PathFailureCause.INVALID_PATH_FORMAT; + } else if (path.contains(DUPLICATE_MARKER)) { + dataPatchFailureCause = PathFailureCause.DUPLICATE_FIELD; + } else if (!(startsWithAllowedPrefix(path) || pathStartsWithAllowedPrefix(path, additionalSupportedPrefixes))) { + dataPatchFailureCause = PathFailureCause.UNSUPPORTED_PREFIX; + } else if (fieldIsForbidden(path)) { + dataPatchFailureCause = PathFailureCause.FORBIDDEN_FIELD; + } else if (fieldIsInvalidMultiField(path)) { + dataPatchFailureCause = PathFailureCause.INVALID_MULTIPLE_FIELDS_FORMAT; + } + return dataPatchFailureCause; + } + + @NotNull + public Tuple resolveAlias(String pathWithPotentialAlias) { + return pathAliasHelper.resolveAlias(pathWithPotentialAlias); + } + + private boolean fieldIsForbidden(String path) { + Set configuredForbiddenFields = resolveConfiguredForbiddenFields(); + return configuredForbiddenFields.contains(path) + || configuredForbiddenFields.stream() + .anyMatch( + forbiddenField -> forbiddenField.startsWith(".") ? path.endsWith(forbiddenField) : configuredForbiddenFields.contains(path)); + } + + private Set resolveConfiguredForbiddenFields() { + String configValue = + systemConfigurationValueFacade != null ? systemConfigurationValueFacade.getValue(PATCH_FORBIDDEN_FIELDS_CONFIG_KEY) : null; + return Optional.ofNullable(configValue) + .filter(v -> !v.isBlank()) + .map(v -> Arrays.stream(v.split(",")).map(String::trim).filter(s -> !s.isEmpty()).collect(Collectors.toSet())) + .orElseGet(DefaultForbiddenFields::getDefaultForbiddenFields); + } + + private boolean startsWithAllowedPrefix(String path) { + return pathStartsWithAllowedPrefix(path, pathAliasHelper.supportedPrefixes()) + || pathStartsWithAllowedPrefix(path, businessDtoFacade.fetchablePrefixes()); + } + + private static boolean pathStartsWithAllowedPrefix(String path, Set prefixes) { + return prefixes.stream().anyMatch(path::startsWith); + } + + public boolean isMultipleFieldFormat(String path) { + return path.contains(OPENING_PARENTHESIS) || path.contains(CLOSING_PARENTHESIS) || path.contains(PIPE); + } + + private boolean fieldIsInvalidMultiField(String path) { + if (!isMultipleFieldFormat(path)) { + return false; + } + + long openCount = path.chars().filter(c -> c == '(').count(); + long closeCount = path.chars().filter(c -> c == ')').count(); + long pipeCount = path.chars().filter(c -> c == '|').count(); + int openIndex = path.indexOf('('); + int closeIndex = path.lastIndexOf(')'); + + if (openCount != 1 || closeCount != 1) { + logger.debug("Path must contain exactly one pair of parentheses: [" + path + "]"); + return true; + } + + if (pipeCount == 0) { + logger.debug("No pipe found [" + path + "]"); + return true; + } + + if (openIndex > closeIndex) { + logger.debug("Closing parenthesis appears before opening parenthesis: [" + path + "]"); + return true; + } + + if (closeIndex != path.length() - 1) { + logger.debug("Closing parenthesis must be at the end of the path: [" + path + "]"); + return true; + } + + String alternatives = path.substring(openIndex + 1, closeIndex); + + if (alternatives.isBlank()) { + logger.debug("Empty parentheses — nothing between '(' and ')': [" + path + "]"); + return true; + } + + String[] parts = alternatives.split("\\|"); + for (String part : parts) { + if (part.isBlank()) { + logger.debug("Empty alternative found — consecutive or leading/trailing pipes: [" + path + "]"); + return true; + } + } + + return false; + } +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/PathFailureCause.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/PathFailureCause.java new file mode 100644 index 00000000000..bf350c9fb09 --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/PathFailureCause.java @@ -0,0 +1,37 @@ +package de.symeda.sormas.backend.patch; + +import javax.validation.constraints.NotNull; + +import de.symeda.sormas.api.patch.DataPatchFailureCause; +import de.symeda.sormas.api.patch.partial_retrieval.PartialRetrievalFailureCause; + +/** + * Occurs when trying to access path. + */ +public enum PathFailureCause { + + INVALID_PATH_FORMAT(DataPatchFailureCause.INVALID_PATH_FORMAT, PartialRetrievalFailureCause.INVALID_PATH_FORMAT), + FORBIDDEN_NON_UNIQUE_ALIAS(DataPatchFailureCause.FORBIDDEN_NON_UNIQUE_ALIAS, PartialRetrievalFailureCause.FORBIDDEN_NON_UNIQUE_ALIAS), + UNSUPPORTED_PREFIX(DataPatchFailureCause.UNSUPPORTED_PREFIX, PartialRetrievalFailureCause.UNSUPPORTED_PREFIX), + FORBIDDEN_FIELD(DataPatchFailureCause.FORBIDDEN_FIELD, PartialRetrievalFailureCause.FORBIDDEN_FIELD), + INVALID_MULTIPLE_FIELDS_FORMAT(DataPatchFailureCause.INVALID_MULTIPLE_FIELDS_FORMAT, PartialRetrievalFailureCause.INVALID_MULTIPLE_FIELDS_FORMAT), + DUPLICATE_FIELD(DataPatchFailureCause.DUPLICATE_FIELD, PartialRetrievalFailureCause.DUPLICATE_FIELD); + + @NotNull + private final DataPatchFailureCause relatedPatchFailureCause; + @NotNull + private final PartialRetrievalFailureCause relatedRetrieveFailureCause; + + PathFailureCause(DataPatchFailureCause relatedPatchFailureCause, PartialRetrievalFailureCause relatedRetrieveFailureCause) { + this.relatedPatchFailureCause = relatedPatchFailureCause; + this.relatedRetrieveFailureCause = relatedRetrieveFailureCause; + } + + public DataPatchFailureCause getRelatedPatchFailureCause() { + return relatedPatchFailureCause; + } + + public PartialRetrievalFailureCause getRelatedRetrieveFailureCause() { + return relatedRetrieveFailureCause; + } +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/PropertyAccessFailure.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/PropertyAccessFailure.java new file mode 100644 index 00000000000..0a3bc02d356 --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/PropertyAccessFailure.java @@ -0,0 +1,32 @@ +package de.symeda.sormas.backend.patch; + +import javax.validation.constraints.NotNull; + +import de.symeda.sormas.api.patch.DataPatchFailureCause; +import de.symeda.sormas.api.patch.partial_retrieval.PartialRetrievalFailureCause; + +public enum PropertyAccessFailure { + + INVALID_INPUT(DataPatchFailureCause.TECHNICAL, PartialRetrievalFailureCause.TECHNICAL), + FIELD_DOES_NOT_EXIST(DataPatchFailureCause.FIELD_DOES_NOT_EXIST, PartialRetrievalFailureCause.FIELD_DOES_NOT_EXIST), + UNSUPPORTED_FIELD_FOR_DISEASE_OR_COUNTRY_OR_FEATURE(DataPatchFailureCause.UNSUPPORTED_FIELD_FOR_DISEASE_OR_COUNTRY_OR_FEATURE, + PartialRetrievalFailureCause.UNSUPPORTED_FIELD_FOR_DISEASE_OR_COUNTRY_OR_FEATURE); + + @NotNull + private final DataPatchFailureCause relatedPatchFailureCause; + @NotNull + private final PartialRetrievalFailureCause relatedRetrieveFailureCause; + + PropertyAccessFailure(DataPatchFailureCause relatedPatchFailureCause, PartialRetrievalFailureCause relatedRetrieveFailureCause) { + this.relatedPatchFailureCause = relatedPatchFailureCause; + this.relatedRetrieveFailureCause = relatedRetrieveFailureCause; + } + + public DataPatchFailureCause getRelatedPatchFailureCause() { + return relatedPatchFailureCause; + } + + public PartialRetrievalFailureCause getRelatedRetrieveFailureCause() { + return relatedRetrieveFailureCause; + } +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/PropertyAccessor.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/PropertyAccessor.java new file mode 100644 index 00000000000..7457126ca15 --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/PropertyAccessor.java @@ -0,0 +1,135 @@ +package de.symeda.sormas.backend.patch; + +import java.lang.reflect.InvocationTargetException; +import java.util.Optional; + +import javax.validation.constraints.NotNull; + +import org.apache.commons.beanutils.PropertyUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import de.symeda.sormas.api.utils.Tuple; +import de.symeda.sormas.api.utils.fieldvisibility.FieldVisibilityCheckers; + +/** + * SORMAS-opinionated reflection accessing of fields. + * Type retrieval caching was not implement due to {@link FieldVisibilityCheckers}. + */ +public class PropertyAccessor { + + private static final Logger logger = LoggerFactory.getLogger(PropertyAccessor.class); + + public static final char PATH_SEPARATOR = '.'; + private static final Tuple, PropertyAccessFailure> FIELD_DOES_NOT_EXIST = Tuple.of(null, PropertyAccessFailure.FIELD_DOES_NOT_EXIST); + + private static final Tuple, PropertyAccessFailure> UNSUPPORTED_FIELD = + Tuple.of(null, PropertyAccessFailure.UNSUPPORTED_FIELD_FOR_DISEASE_OR_COUNTRY_OR_FEATURE); + + private static final Tuple, PropertyAccessFailure> INVALID_INPUT = Tuple.of(null, PropertyAccessFailure.INVALID_INPUT); + + private PropertyAccessor() { + } + + public static Tuple, Object>, PropertyAccessFailure> getNestedPropertyAndType( + final Object bean, + final String fieldName, + FieldVisibilityCheckers fieldVisibilityCheckers) { + if (bean == null || fieldName == null || fieldName.isEmpty()) { + return new Tuple<>(null, PropertyAccessFailure.INVALID_INPUT); + } + + boolean notNestedPath = fieldName.indexOf(PATH_SEPARATOR) == fieldName.lastIndexOf(PATH_SEPARATOR); + + if (notNestedPath) { + return getPropertyTypeAndValue(bean, fieldName, fieldVisibilityCheckers); + } + + String leafPath = fieldName.substring(fieldName.lastIndexOf(PATH_SEPARATOR) + 1); + + return getNestedProperty(bean, fieldName).map(leafParent -> getPropertyTypeAndValue(leafParent, leafPath, fieldVisibilityCheckers)) + .orElseGet(() -> new Tuple<>(null, PropertyAccessFailure.FIELD_DOES_NOT_EXIST)); + } + + public static Tuple, PropertyAccessFailure> getNestedPropertyType( + final Object bean, + final String fieldName, + FieldVisibilityCheckers fieldVisibilityCheckers) { + if (bean == null || fieldName == null || fieldName.isEmpty()) { + return INVALID_INPUT; + } + + boolean notNestedPath = fieldName.indexOf(PATH_SEPARATOR) == fieldName.lastIndexOf(PATH_SEPARATOR); + + if (notNestedPath) { + return getPropertyType(bean, fieldName, fieldVisibilityCheckers); + } + + String leafPath = fieldName.substring(fieldName.lastIndexOf(PATH_SEPARATOR) + 1); + + return getNestedProperty(bean, fieldName).map(leafParent -> getPropertyType(leafParent, leafPath, fieldVisibilityCheckers)) + .orElse(FIELD_DOES_NOT_EXIST); + } + + @NotNull + public static Tuple, Object>, PropertyAccessFailure> getPropertyTypeAndValue( + final Object bean, + final String fieldName, + FieldVisibilityCheckers fieldVisibilityCheckers) { + try { + return Optional.ofNullable(PropertyUtils.getPropertyType(bean, fieldName)) + ., Object>, PropertyAccessFailure>> map(propertyType -> { + boolean visible = fieldVisibilityCheckers.isVisible(bean.getClass(), fieldName); + + if (!visible) { + return Tuple.of(null, PropertyAccessFailure.UNSUPPORTED_FIELD_FOR_DISEASE_OR_COUNTRY_OR_FEATURE); + } + + return Tuple.of(Tuple.of(propertyType, getNestedProperty(bean, fieldName).orElse(null)), null); + }) + .orElseGet(() -> Tuple.of(null, PropertyAccessFailure.FIELD_DOES_NOT_EXIST)); + } catch (IllegalAccessException | InvocationTargetException | NoSuchMethodException e) { + logger.info("Could not get property type for [{}], [{}]", fieldName, bean, e); + return null; + } + } + + public static Tuple, PropertyAccessFailure> getPropertyType( + final Object bean, + final String fieldName, + FieldVisibilityCheckers fieldVisibilityCheckers) { + try { + return Optional.ofNullable(PropertyUtils.getPropertyType(bean, fieldName))., PropertyAccessFailure>> map(propertyType -> { + boolean visible = fieldVisibilityCheckers.isVisible(bean.getClass(), fieldName); + + if (!visible) { + return UNSUPPORTED_FIELD; + } + + return new Tuple<>(propertyType, null); + }).orElse(FIELD_DOES_NOT_EXIST); + } catch (IllegalAccessException | InvocationTargetException | NoSuchMethodException e) { + logger.info("Could not get property type for [{}], [{}]", fieldName, bean, e); + return FIELD_DOES_NOT_EXIST; + } + } + + public static Optional getNestedProperty(final Object bean, final String name) { + try { + return Optional.ofNullable(PropertyUtils.getNestedProperty(bean, name)); + } catch (IllegalAccessException | InvocationTargetException | NoSuchMethodException e) { + logger.info("Could not get property value for [{}], [{}]", name, bean, e); + return Optional.empty(); + } + } + + public static Optional setNestedProperty(final Object bean, final String name, final Object value) { + try { + PropertyUtils.setNestedProperty(bean, name, value); + return Optional.empty(); + } catch (IllegalAccessException | InvocationTargetException | NoSuchMethodException e) { + logger.info("Could not set property for bean [{}], name [{}], value [{}]", bean, name, value, e); + return Optional.of(e); + } + } +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/alias/PathAliasFacadeEjb.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/alias/PathAliasFacadeEjb.java new file mode 100644 index 00000000000..195f5cb8f6a --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/alias/PathAliasFacadeEjb.java @@ -0,0 +1,25 @@ +package de.symeda.sormas.backend.patch.alias; + +import javax.ejb.LocalBean; +import javax.ejb.Stateless; +import javax.inject.Inject; + +import de.symeda.sormas.api.survey.alias.PathAliasFacade; + +@Stateless(name = "PathAliasFacade") +public class PathAliasFacadeEjb implements PathAliasFacade { + + @Inject + private PathAliasHelper pathAliasHelper; + + @Override + public String fetchAliasPath(String path) { + return pathAliasHelper.toAliasPath(path); + } + + @LocalBean + @Stateless + public static class PathAliasFacadeEjbLocal extends PathAliasFacadeEjb { + + } +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/alias/PathAliasHelper.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/alias/PathAliasHelper.java new file mode 100644 index 00000000000..0c8b162b43b --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/alias/PathAliasHelper.java @@ -0,0 +1,172 @@ +package de.symeda.sormas.backend.patch.alias; + +import static de.symeda.sormas.backend.patch.PatchFieldHelper.PATH_SEPARATOR; + +import java.util.AbstractMap; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import javax.enterprise.context.ApplicationScoped; +import javax.validation.constraints.NotNull; + +import de.symeda.sormas.api.activityascase.ActivityAsCaseDto; +import de.symeda.sormas.api.epidata.EpiDataDto; +import de.symeda.sormas.api.immunization.ImmunizationDto; +import de.symeda.sormas.api.vaccination.VaccinationDto; +import org.apache.commons.collections4.CollectionUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import de.symeda.sormas.api.caze.CaseDataDto; +import de.symeda.sormas.api.clinicalcourse.HealthConditionsDto; +import de.symeda.sormas.api.exposure.ExposureDto; +import de.symeda.sormas.api.hospitalization.HospitalizationDto; +import de.symeda.sormas.api.hospitalization.PreviousHospitalizationDto; +import de.symeda.sormas.api.infrastructure.community.CommunityDto; +import de.symeda.sormas.api.infrastructure.continent.ContinentDto; +import de.symeda.sormas.api.infrastructure.country.CountryDto; +import de.symeda.sormas.api.infrastructure.district.DistrictDto; +import de.symeda.sormas.api.infrastructure.facility.FacilityDto; +import de.symeda.sormas.api.infrastructure.pointofentry.PointOfEntryDto; +import de.symeda.sormas.api.infrastructure.region.RegionDto; +import de.symeda.sormas.api.infrastructure.subcontinent.SubcontinentDto; +import de.symeda.sormas.api.location.LocationDto; +import de.symeda.sormas.api.person.PersonContactDetailDto; +import de.symeda.sormas.api.person.PersonDto; +import de.symeda.sormas.api.symptoms.SymptomsDto; +import de.symeda.sormas.api.user.UserDto; +import de.symeda.sormas.api.utils.Tuple; +import de.symeda.sormas.backend.patch.PathFailureCause; + +/** + * Users want to be able to use multiple root objects, to avoid drilling through properties. + * Therefore, this class performs this manual conversion. + *

+ * FieldId from Data dictionary to DTO's physical path. + */ +@ApplicationScoped +public class PathAliasHelper { + + private final static Logger logger = LoggerFactory.getLogger(PathAliasHelper.class); + + public static final Map DEFAULT_ALIAS_DICTIONARY = buildDefaultAliasDictionary(); + + /** + * Can be used as OUT: for displaying purposes but not for in. + */ + public static final Map> DEFAULT_FORBIDDEN_ALIASES_DICTIONARY = Map.of( + "Location", + Set.of(toFieldName(PersonDto.I18N_PREFIX, PersonDto.ADDRESS), toFieldName(ExposureDto.I18N_PREFIX, ExposureDto.LOCATION))); + + /** + * Meant for fields that are only references from another entity. + */ + public static final Map REFERENCE_TO_ROOT_DICTIONARY = Map.of( + toFieldName(CaseDataDto.I18N_PREFIX, CaseDataDto.PERSON), + PersonDto.I18N_PREFIX); + + private static @NotNull HashMap buildDefaultAliasDictionary() { + HashMap dictionary = new HashMap<>(); + + dictionary.put(SymptomsDto.I18N_PREFIX, toFieldName(CaseDataDto.I18N_PREFIX, CaseDataDto.SYMPTOMS)); + dictionary.put(HealthConditionsDto.I18N_PREFIX, toFieldName(CaseDataDto.I18N_PREFIX, CaseDataDto.HEALTH_CONDITIONS)); + dictionary.put(HospitalizationDto.I18N_PREFIX, toFieldName(CaseDataDto.I18N_PREFIX, CaseDataDto.HOSPITALIZATION)); + dictionary.put(PersonContactDetailDto.I18N_PREFIX, toFieldName(PersonDto.I18N_PREFIX, PersonDto.PERSON_CONTACT_DETAILS)); + dictionary.put(FacilityDto.I18N_PREFIX, toFieldName(CaseDataDto.I18N_PREFIX, CaseDataDto.HEALTH_FACILITY)); + dictionary.put(PointOfEntryDto.I18N_PREFIX, toFieldName(CaseDataDto.I18N_PREFIX, CaseDataDto.POINT_OF_ENTRY)); + dictionary.put(RegionDto.I18N_PREFIX, toFieldName(CaseDataDto.I18N_PREFIX, CaseDataDto.RESPONSIBLE_REGION)); + dictionary.put(DistrictDto.I18N_PREFIX, toFieldName(CaseDataDto.I18N_PREFIX, CaseDataDto.RESPONSIBLE_DISTRICT)); + dictionary.put(CommunityDto.I18N_PREFIX, toFieldName(CaseDataDto.I18N_PREFIX, CaseDataDto.RESPONSIBLE_COMMUNITY)); + dictionary.put(CountryDto.I18N_PREFIX, toFieldName(PersonDto.I18N_PREFIX, PersonDto.BIRTH_COUNTRY)); + dictionary.put(SubcontinentDto.I18N_PREFIX, toFieldName(toFieldName(PersonDto.I18N_PREFIX, PersonDto.ADDRESS), LocationDto.SUB_CONTINENT)); + dictionary.put(ContinentDto.I18N_PREFIX, toFieldName(toFieldName(PersonDto.I18N_PREFIX, PersonDto.ADDRESS), LocationDto.CONTINENT)); + dictionary.put(UserDto.I18N_PREFIX, toFieldName(CaseDataDto.I18N_PREFIX, CaseDataDto.FOLLOW_UP_STATUS_CHANGE_USER)); + + dictionary.put(EpiDataDto.I18N_PREFIX, toFieldName(CaseDataDto.I18N_PREFIX, CaseDataDto.EPI_DATA)); + + return dictionary; + } + + private static String toFieldName(String prefix, String fieldName) { + return prefix + '.' + fieldName; + } + + @NotNull + public Tuple resolveAlias(String pathWithPotentialAlias) { + int firstPathSeparatorIndex = pathWithPotentialAlias.indexOf(PATH_SEPARATOR); + + if (firstPathSeparatorIndex == -1) { + return tupleWithoutFailure(pathWithPotentialAlias); + } + + String aliasCandidate = pathWithPotentialAlias.substring(0, firstPathSeparatorIndex); + + Set collisions = DEFAULT_FORBIDDEN_ALIASES_DICTIONARY.get(aliasCandidate); + if (CollectionUtils.isNotEmpty(collisions)) { + logger.info("Alias [{}] with collisions: [{}] used for path as [{}]", pathWithPotentialAlias, collisions, aliasCandidate); + return tupleWithFailure(PathFailureCause.FORBIDDEN_NON_UNIQUE_ALIAS); + } + + String pathWithFixedRootObjectReferences = REFERENCE_TO_ROOT_DICTIONARY.values() + .stream() + .reduce( + pathWithPotentialAlias, + (path, replacement) -> path.replace( + REFERENCE_TO_ROOT_DICTIONARY.entrySet().stream().filter(entry -> replacement.equals(entry.getValue())).findFirst().get().getKey(), + replacement)); + + String physicalPathPrefix = DEFAULT_ALIAS_DICTIONARY.get(aliasCandidate); + if (physicalPathPrefix != null) { + return tupleWithoutFailure(physicalPathPrefix + pathWithFixedRootObjectReferences.substring(firstPathSeparatorIndex)); + } + + return tupleWithoutFailure(pathWithFixedRootObjectReferences); + } + + /** + * Objective is to retrieve a path WITH an alias to get the Field ID as in the data dictionary. + * + * @param path + * field that may or may not contain a "physical-path" that must be "shortened" to the Field ID. + * @return path that is "shortened" to a path using the Field ID (alias). + */ + public String toAliasPath(String path) { + Set> reduce = Stream.concat( + DEFAULT_ALIAS_DICTIONARY.entrySet().stream(), + DEFAULT_FORBIDDEN_ALIASES_DICTIONARY.entrySet() + .stream() + .flatMap(entry -> entry.getValue().stream().map(replacementPath -> new AbstractMap.SimpleEntry<>(entry.getKey(), replacementPath)))) + .collect(Collectors.toSet()); + + for (Map.Entry entry : reduce) { + path = path.replace(entry.getValue(), entry.getKey()); + } + + for (Map.Entry entry : REFERENCE_TO_ROOT_DICTIONARY.entrySet()) { + path = path.replace(entry.getKey(), entry.getValue()); + } + + return path; + } + + private static @NotNull Tuple tupleWithFailure(PathFailureCause forbiddenNonUniqueAlias) { + return new Tuple<>(null, forbiddenNonUniqueAlias); + } + + private static @NotNull Tuple tupleWithoutFailure(String pathWithPotentialAlias) { + return new Tuple<>(pathWithPotentialAlias, null); + } + + public Set supportedPrefixes() { + return Stream + .concat( + Stream.concat(REFERENCE_TO_ROOT_DICTIONARY.values().stream(), Stream.of(CaseDataDto.I18N_PREFIX)) + .map(prefix -> prefix + PATH_SEPARATOR), + DEFAULT_ALIAS_DICTIONARY.keySet().stream()) + .collect(Collectors.toSet()); + + } +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/fields.js b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/fields.js new file mode 100644 index 00000000000..0fe12fd6dcd --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/fields.js @@ -0,0 +1,332 @@ +const fields = [...new Set([ + "Person.personContactDetails.details", + "Person.personContactDetails.details", + "", + "N/A", + "Person.birthdate", + "Person.sex", + "Person.occupationType", + "Person.occupationDetails", + "Person.workPlace & Person.workPlaceText ", + "Person.workPlaceText ", + "", + "N/A", + "N/A", + "", + "N/A", + "N/A", + "N/A", + "CaseData.symptoms.onsetDate", + "", + "CaseData.symptoms.reoccurrence", + "", + "CaseData.symptoms.abdominalPain", + "CaseData.symptoms.diarrhea", + "CaseData.symptoms.nausea", + "N/A", + "CaseData.symptoms.dehydration", + "CaseData.symptoms.fever", + "CaseData.symptoms.weakness", + "N/A", + "N/A", + "", + "CaseData.hospitalization.admittedToHealthFacility", + "CaseData.hospitalization.admissionDate", + "CaseData.hospitalization.dischargeDate", + "CaseData.hospitalization.currentlyHospitalized", + "", + "N/A", + "N/A", + "N/A", + "N/A", + "N/A", + "N/A", + "N/A", + "N/A", + "N/A", + "N/A", + "N/A", + "N/A", + "N/A", + "", + "", + "N/A", + "N/A", + "N/A", + "N/A", + "", + "", + "N/A", + "N/A", + "N/A", + "N/A", + "N/A", + "N/A", + "N/A", + "N/A", + "N/A", + "N/A", + "N/A", + "N/A", + "N/A", + "N/A", + "", + "CaseData.additionalDetails", + "", + "Person.personContactDetails.details", + "Person.personContactDetails.details", + "", + "N/A", + "Person.birthdate", + "Person.sex", + "Person.occupationType", + "Person.workPlace", + "Person.occupationDetails", + "", + "CaseData.additionalDetails", + "CaseData.additionalDetails", + "", + "N/A", + "N/A", + "", + "N/A", + "CaseData.symptoms.onsetDate", + "", + "CaseData.symptoms.abdominalPain", + "CaseData.symptoms.diarrhea", + "CaseData.symptoms.nausea", + "CaseData.symptoms.vomiting", + "CaseData.symptoms.lossOfAppetite", + "-", + "CaseData.symptoms.dehydration", + "CaseData.symptoms.fever", + "CaseData.symptoms.weakness", + "CaseData.symptoms.lossOfAppetite", + "-", + "-", + "-", + "CaseData.symptoms.otherNonHemorrhagicSymptoms", + "", + "CaseData.hospitalization.admittedToHealthFacility", + "CaseData.hospitalization.dischargeDate - admissionDate", + "CaseData.hospitalization.healthFacility", + "CaseData.hospitalization.admissionDate", + "CaseData.hospitalization.dischargeDate", + "", + "CaseData.sequelae", + "", + "N/A", + "N/A", + "N/A", + "N/A", + "N/A", + "N/A", + "", + "N/A", + "N/A", + "N/A", + "N/A", + "", + "N/A", + "", + "N/A", + "N/A", + "", + "-", + "-", + "", + "N/A", + "N/A", + "N/A", + "N/A", + "N/A", + "N/A", + "N/A", + "N/A", + "N/A", + "N/A", + "N/A", + "N/A", + "N/A", + "", + "N/A", + "N/A", + "EpiData.sexualTransmissionSuspected", + "", + "CaseData.additionalDetails", + "CaseData.additionalDetails", + "", + "Person.firstName", + "Person.lastName", + "Person.birthdateDD/MM/YYYY", + "", + "Person.gestationalAgeCategory", + "Person.birthWeightCategory", + "Person.birthWeight", + "Person.multipleBirth", + "", + "CaseData.healthConditions.recurrentBronchiolitis", + "CaseData.healthConditions.", + "CaseData.healthConditions.immunodeficiencyOtherThanHiv", + "CaseData.healthConditions.chronicNeurologicCondition", + "CaseData.healthConditions.chronicHeartFailure", + "CaseData.additionalDetails.otherConditions", + "", + "CaseData.vaccinationStatus", + "CaseData.vaccinationStatusDetails", + "CaseData.vaccinationStatusDetails", + "CaseData.vaccinationStatusDetails", + "", + "CaseData.symptoms.fever", + "CaseData.symptoms.runnyNose", + "CaseData.symptoms.wheezing", + "N/A", + "N/A", + "CaseData.symptoms.retractions", + "CaseData.symptoms.dyspnea", + "CaseData.symptoms.lossOfAppetite", + "CaseData.symptoms.cough", + "N/A", + "", + "CaseData.symptoms.onsetDate", + "", + "CaseData.hospitalization.admittedToHealthFacility", + "CaseData.hospitalization.healthFacilityDetails", + "CaseData.hospitalization.admissionDate", + "CaseData.hospitalization.dischargeDate", + "CaseData.hospitalization.icuAdmission", + "CaseData.hospitalization.icuLengthOfStay", + "CaseData.hospitalization.oxygenTherapy", + "", + "CaseData.additionalDetails", + "-", + "", + "Person.workPlace", + "CaseData.additionalDetails", + "", + "Person.personContactDetails.details", + "Person.personContactDetails.details", + "", + "CaseData.additionalDetails", + "", + "Person.personContactDetails.details", + "PersonContactDetail.details", + "", + "Person.firstName", + "Person.address.country", + "Person.sex", + "Person.birthdate", + "", + "CaseData.vaccinationStatus", + "CaseData.vaccinationStatusDetails", + "CaseData.vaccinationStatusDetails", + "CaseData.vaccinationStatusDetails", + "", + "Symptoms.onsetDate", + "Symptoms.endDate", + "CaseData.additionalDetails", + "", + "Symptoms.fever", + "Symptoms.cough", + "Symptoms.cough", + "Symptoms.cough", + "Symptoms.otherNonHemorrhagicSymptoms", + "", + "Hospitalization.admittedToHealthFacility", + "Hospitalization.admissionDate", + "Hospitalization.dischargeDate", + "Hospitalization.description", + "", + "CaseData.treatmentStarted", + "medicationDetails", + "", + "exposureDetails", + "directContactConfirmedCase", + "directContactConfirmedCase", + "additionalDetails", + "", + "Person.educationType & Person.educationDetails", + "Person.educationDetails", + "Person.educationDetails", + "Person.educationDetails", + +])].filter(name => !(name === "" || name === "N/A" || name === "-")).toSorted(); + +console.log(fields) + + +/* +RESULTS +[ + 'CaseData.additionalDetails', + 'CaseData.additionalDetails.otherConditions', + 'CaseData.healthConditions.', + 'CaseData.healthConditions.chronicHeartFailure', + 'CaseData.healthConditions.chronicNeurologicCondition', + 'CaseData.healthConditions.immunodeficiencyOtherThanHiv', + 'CaseData.healthConditions.recurrentBronchiolitis', + 'CaseData.hospitalization.admissionDate', + 'CaseData.hospitalization.admittedToHealthFacility', + 'CaseData.hospitalization.currentlyHospitalized', + 'CaseData.hospitalization.dischargeDate', + 'CaseData.hospitalization.dischargeDate - admissionDate', + 'CaseData.hospitalization.healthFacility', + 'CaseData.hospitalization.healthFacilityDetails', + 'CaseData.hospitalization.icuAdmission', + 'CaseData.hospitalization.icuLengthOfStay', + 'CaseData.hospitalization.oxygenTherapy', + 'CaseData.sequelae', + 'CaseData.symptoms.abdominalPain', + 'CaseData.symptoms.cough', + 'CaseData.symptoms.dehydration', + 'CaseData.symptoms.diarrhea', + 'CaseData.symptoms.dyspnea', + 'CaseData.symptoms.fever', + 'CaseData.symptoms.lossOfAppetite', + 'CaseData.symptoms.nausea', + 'CaseData.symptoms.onsetDate', + 'CaseData.symptoms.otherNonHemorrhagicSymptoms', + 'CaseData.symptoms.reoccurrence', + 'CaseData.symptoms.retractions', + 'CaseData.symptoms.runnyNose', + 'CaseData.symptoms.vomiting', + 'CaseData.symptoms.weakness', + 'CaseData.symptoms.wheezing', + 'CaseData.treatmentStarted', + 'CaseData.vaccinationStatus', + 'CaseData.vaccinationStatusDetails', + 'EpiData.sexualTransmissionSuspected', + 'Hospitalization.admissionDate', + 'Hospitalization.admittedToHealthFacility', + 'Hospitalization.description', + 'Hospitalization.dischargeDate', + 'Person.address.country', + 'Person.birthWeight', + 'Person.birthWeightCategory', + 'Person.birthdate', + 'Person.birthdateDD/MM/YYYY', + 'Person.educationDetails', + 'Person.educationType & Person.educationDetails', + 'Person.firstName', + 'Person.gestationalAgeCategory', + 'Person.lastName', + 'Person.multipleBirth', + 'Person.occupationDetails', + 'Person.occupationType', + 'Person.personContactDetails.details', + 'Person.sex', + 'Person.workPlace', + 'Person.workPlace & Person.workPlaceText ', + 'Person.workPlaceText ', + 'PersonContactDetail.details', + 'Symptoms.cough', + 'Symptoms.endDate', + 'Symptoms.fever', + 'Symptoms.onsetDate', + 'Symptoms.otherNonHemorrhagicSymptoms', + 'additionalDetails', + 'directContactConfirmedCase', + 'exposureDetails', + 'medicationDetails' +] +*/ \ No newline at end of file diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/mapping/FieldCustomMapperRegistry.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/mapping/FieldCustomMapperRegistry.java new file mode 100644 index 00000000000..fd173fca96e --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/mapping/FieldCustomMapperRegistry.java @@ -0,0 +1,34 @@ +package de.symeda.sormas.backend.patch.mapping; + +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +import javax.annotation.PostConstruct; +import javax.enterprise.context.ApplicationScoped; +import javax.enterprise.inject.Instance; +import javax.inject.Inject; + +import de.symeda.sormas.api.Disease; +import de.symeda.sormas.api.patch.mapping.FieldCustomMapper; +import de.symeda.sormas.api.utils.Tuple; + +@ApplicationScoped +public class FieldCustomMapperRegistry { + + @Inject + private Instance instances; + + private Map dictionary; + + @PostConstruct + void init() { + dictionary = instances.stream() + .flatMap(mapperInstance -> mapperInstance.supportedFields().stream().map(field -> new Tuple<>(field, mapperInstance))) + .collect(Collectors.toMap(Tuple::getFirst, Tuple::getSecond)); + } + + public Optional getMapper(final String fieldName, Disease disease) { + return Optional.ofNullable(dictionary.get(fieldName)).filter(mapper -> mapper.supportedDisease().contains(disease)); + } +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/mapping/PatchEqualityCheckersRegistry.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/mapping/PatchEqualityCheckersRegistry.java new file mode 100644 index 00000000000..e6d4fd5c158 --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/mapping/PatchEqualityCheckersRegistry.java @@ -0,0 +1,64 @@ +package de.symeda.sormas.backend.patch.mapping; + +import java.util.List; +import java.util.stream.Collectors; + +import javax.annotation.PostConstruct; +import javax.enterprise.context.ApplicationScoped; +import javax.enterprise.inject.Instance; +import javax.inject.Inject; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@ApplicationScoped +public class PatchEqualityCheckersRegistry { + + private final static Logger logger = LoggerFactory.getLogger(PatchEqualityCheckersRegistry.class); + + private List orderedInstances; + + @Inject + private Instance instances; + + public PatchEqualityCheckersRegistry() { + } + + public PatchEqualityCheckersRegistry(Instance instances) { + this.instances = instances; + } + + @PostConstruct + void init() { + orderedInstances = instances.stream().sorted().collect(Collectors.toList()); + } + + public boolean areEqual(Object a, Object b) { + if (a == null && b == null) { + logger.debug("Both values were null, returning true"); + return true; + } + if (a == null || b == null) { + logger.debug("One of both value was null, returning false."); + return false; + } + + Class type = a.getClass(); + PatchingEqualityChecker checker = orderedInstances.stream() + .filter(c -> c.supports(type)) + .findFirst() + .orElseThrow( + () -> new IllegalStateException( + String.format( + "No equality checker found for: [%s]. Must not occur, ObjectEqualityChecker handles Object. Registered checkers: [%s]", + type, + orderedInstances))); + + logger.debug("Values [{}] and [{}] will be compared with checker: [{}]", a, b, checker); + boolean areEqual = checker.areEqual(a, b); + + logger.debug("areEqual: [{}]", areEqual); + + return areEqual; + } +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/mapping/PatchingEqualityChecker.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/mapping/PatchingEqualityChecker.java new file mode 100644 index 00000000000..8b243899f4e --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/mapping/PatchingEqualityChecker.java @@ -0,0 +1,18 @@ +package de.symeda.sormas.backend.patch.mapping; + +import de.symeda.sormas.api.utils.OrderedRegisterable; + +/** + * Contract to specify how two values of a supported type should be compared for equality. + */ +public interface PatchingEqualityChecker extends OrderedRegisterable { + + /** + * @param a + * first value — guaranteed non-null by the registry + * @param b + * second value — guaranteed non-null by the registry + * @return true if the two values are considered equal for patch purposes + */ + boolean areEqual(Object a, Object b); +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/mapping/ValueMapperRegistry.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/mapping/ValueMapperRegistry.java new file mode 100644 index 00000000000..3874611d877 --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/mapping/ValueMapperRegistry.java @@ -0,0 +1,70 @@ +package de.symeda.sormas.backend.patch.mapping; + +import java.util.List; +import java.util.stream.Collectors; + +import javax.annotation.PostConstruct; +import javax.enterprise.context.ApplicationScoped; +import javax.enterprise.inject.Instance; +import javax.inject.Inject; +import javax.validation.constraints.NotNull; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import de.symeda.sormas.api.patch.DataPatchFailureCause; +import de.symeda.sormas.api.patch.mapping.ValueMappingResult; +import de.symeda.sormas.api.patch.mapping.ValuePatchMapper; +import de.symeda.sormas.api.patch.mapping.ValuePatchRequest; + +@ApplicationScoped +public class ValueMapperRegistry { + + private final static Logger logger = LoggerFactory.getLogger(ValueMapperRegistry.class); + private List orderedInstances; + + @Inject + private Instance instances; + + public ValueMapperRegistry() { + } + + public ValueMapperRegistry(Instance instances) { + this.instances = instances; + } + + @PostConstruct + void init() { + // default sort uses CDI sort. + orderedInstances = instances.stream().sorted().collect(Collectors.toList()); + } + + @NotNull + public ValueMappingResult map(ValuePatchRequest request) { + Class targetType = request.getTargetType(); + + if (targetType == Object.class) { + logger.error("Object is not a supported targetType"); + return ValueMappingResult.withCause(DataPatchFailureCause.TECHNICAL); + } + + Object value = request.getValue(); + if (value == null) { + return ValueMappingResult.withData(null); + } + + if (targetType.isInstance(value)) { + return ValueMappingResult.withData((T) targetType.cast(value)); + } + + for (ValuePatchMapper mapper : orderedInstances) { + if (mapper.supports(targetType)) { + return mapper.map(request); + } + } + + logger.error("No mapper found for: [{}]", targetType); + + return ValueMappingResult.withCause(DataPatchFailureCause.UNSUPPORTED_TARGET_TYPE); + } +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/mapping/impl/equalitychecker/DatePatchingEqualityChecker.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/mapping/impl/equalitychecker/DatePatchingEqualityChecker.java new file mode 100644 index 00000000000..a693d9ba32f --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/mapping/impl/equalitychecker/DatePatchingEqualityChecker.java @@ -0,0 +1,42 @@ +package de.symeda.sormas.backend.patch.mapping.impl.equalitychecker; + +import java.time.ZoneId; +import java.util.Date; +import java.util.Set; + +import javax.enterprise.context.ApplicationScoped; + +import de.symeda.sormas.backend.patch.mapping.PatchingEqualityChecker; + +/** + * Dates are stored as {@link Date} but only the day is relevant, not time therefore using this approach. + */ +@ApplicationScoped +public class DatePatchingEqualityChecker implements PatchingEqualityChecker { + + public static final Set> SUPPORTED_TYPES = Set.of(Date.class); + + @Override + public boolean areEqual(Object a, Object b) { + if (a == null && b == null) { + return true; + } else if (a == null || b == null) { + return false; + } + return toLocalDate((Date) a).equals(toLocalDate((Date) b)); + } + + @Override + public Set> getSupportedTypes() { + return SUPPORTED_TYPES; + } + + @Override + public int getOrder() { + return HIGH_PRECEDENCE; + } + + private java.time.LocalDate toLocalDate(Date date) { + return date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate(); + } +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/mapping/impl/equalitychecker/ObjectPatchingEqualityChecker.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/mapping/impl/equalitychecker/ObjectPatchingEqualityChecker.java new file mode 100644 index 00000000000..43af07e31b4 --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/mapping/impl/equalitychecker/ObjectPatchingEqualityChecker.java @@ -0,0 +1,24 @@ +package de.symeda.sormas.backend.patch.mapping.impl.equalitychecker; + +import java.util.Objects; +import java.util.Set; + +import javax.enterprise.context.ApplicationScoped; + +import de.symeda.sormas.backend.patch.mapping.PatchingEqualityChecker; + +@ApplicationScoped +public class ObjectPatchingEqualityChecker implements PatchingEqualityChecker { + + public static final Set> SUPPORTED_TYPES = Set.of(Object.class); + + @Override + public boolean areEqual(Object a, Object b) { + return Objects.equals(a, b); + } + + @Override + public Set> getSupportedTypes() { + return SUPPORTED_TYPES; + } +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/mapping/impl/fieldmapper/PersonBirthDateFieldMapper.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/mapping/impl/fieldmapper/PersonBirthDateFieldMapper.java new file mode 100644 index 00000000000..1d8a31ebfad --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/mapping/impl/fieldmapper/PersonBirthDateFieldMapper.java @@ -0,0 +1,81 @@ +package de.symeda.sormas.backend.patch.mapping.impl.fieldmapper; + +import java.util.Calendar; +import java.util.Date; +import java.util.Optional; +import java.util.Set; + +import javax.enterprise.context.ApplicationScoped; +import javax.inject.Inject; + +import de.symeda.sormas.api.patch.DataPatchFailure; +import de.symeda.sormas.api.patch.DataPatchFailureCause; +import de.symeda.sormas.api.patch.DataReplacementStrategy; +import de.symeda.sormas.api.patch.mapping.FieldCustomMapper; +import de.symeda.sormas.api.patch.mapping.FieldPatchRequest; +import de.symeda.sormas.api.patch.mapping.ValueMappingResult; +import de.symeda.sormas.api.person.PersonDto; +import de.symeda.sormas.backend.patch.PatchFieldHelper; +import de.symeda.sormas.backend.patch.mapping.impl.valuemapper.DatePatchMapper; + +/** + * For now this FieldMapper will not be allowed and "deactivated" through the list of forbidden fields. + */ +@ApplicationScoped +public class PersonBirthDateFieldMapper implements FieldCustomMapper { + + @Inject + private DatePatchMapper dateMapper; + + @Override + public Optional map(FieldPatchRequest request) { + + Object untypedTarget = request.getTarget(); + if (!(untypedTarget instanceof PersonDto)) { + return Optional.of(new DataPatchFailure().setDataPatchFailureCause(DataPatchFailureCause.TECHNICAL)); + } + + PersonDto person = (PersonDto) untypedTarget; + + Object untypedValue = request.getValue(); + ValueMappingResult result = dateMapper.map(untypedValue, Date.class); + + DataPatchFailureCause dataPatchFailureCause = result.getDataPatchFailureCause(); + if (dataPatchFailureCause != null) { + return Optional.of(new DataPatchFailure().setDataPatchFailureCause(dataPatchFailureCause)); + } + + Date birthDate = result.getData(); + + Calendar calendar = Calendar.getInstance(); + calendar.setTime(birthDate); + int dayOfMonth = calendar.get(Calendar.DAY_OF_MONTH); + int birthdateMonth = calendar.get(Calendar.MONTH) + 1; + int year = calendar.get(Calendar.YEAR); + + if (request.getReplacementType() == DataReplacementStrategy.IF_NOT_ALREADY_PRESENT) { + + Integer currentDayOfMonth = person.getBirthdateDD(); + Integer currentBirthdateMonth = person.getBirthdateMM(); + Integer currentYear = person.getBirthdateYYYY(); + + if (currentDayOfMonth != dayOfMonth || currentBirthdateMonth != birthdateMonth || currentYear != year) { + return Optional.of( + new DataPatchFailure().setDataPatchFailureCause(DataPatchFailureCause.FORBIDDEN_VALUE_OVERRIDE) + .setProvidedFieldValue(untypedValue)); + } + + } + + person.setBirthdateMM(birthdateMonth); + person.setBirthdateDD(dayOfMonth); + person.setBirthdateYYYY(year); + + return Optional.empty(); + } + + @Override + public Set supportedFields() { + return Set.of(PersonDto.I18N_PREFIX + PatchFieldHelper.PATH_SEPARATOR + PersonDto.BIRTH_DATE); + } +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/mapping/impl/fieldmapper/PersonContactDetailsFieldMapper.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/mapping/impl/fieldmapper/PersonContactDetailsFieldMapper.java new file mode 100644 index 00000000000..45a24858bc9 --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/mapping/impl/fieldmapper/PersonContactDetailsFieldMapper.java @@ -0,0 +1,110 @@ +package de.symeda.sormas.backend.patch.mapping.impl.fieldmapper; + +import static de.symeda.sormas.backend.patch.PatchFieldHelper.PATH_SEPARATOR; + +import java.util.Optional; +import java.util.Set; +import java.util.function.Predicate; +import java.util.function.Supplier; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import javax.enterprise.context.ApplicationScoped; + +import org.apache.commons.lang3.ObjectUtils; +import org.apache.commons.lang3.StringUtils; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import de.symeda.sormas.api.patch.DataPatchFailure; +import de.symeda.sormas.api.patch.DataPatchFailureCause; +import de.symeda.sormas.api.patch.mapping.FieldCustomMapper; +import de.symeda.sormas.api.patch.mapping.FieldPatchRequest; +import de.symeda.sormas.api.person.PersonContactDetailDto; +import de.symeda.sormas.api.person.PersonContactDetailType; +import de.symeda.sormas.api.person.PersonDto; +import de.symeda.sormas.api.person.PhoneNumberType; +import de.symeda.sormas.api.utils.DataHelper; + +/** + * Allows to set the phone number or email contact info of a person with within a single mapper. + */ +@ApplicationScoped +public class PersonContactDetailsFieldMapper implements FieldCustomMapper { + + private static final Logger logger = LoggerFactory.getLogger(PersonContactDetailsFieldMapper.class); + + @Override + public Optional map(FieldPatchRequest request) { + Object untypedTarget = request.getTarget(); + if (!(untypedTarget instanceof PersonDto)) { + return Optional.of(new DataPatchFailure().setDataPatchFailureCause(DataPatchFailureCause.TECHNICAL)); + } + PersonDto personDto = (PersonDto) untypedTarget; + + Supplier appropriateSupplier; + Predicate appropriatePredicate; + + if (request.getFieldName().contains(PersonContactDetailDto.PHONE_NUMBER_TYPE)) { + appropriatePredicate = buildPredicateFor(PersonContactDetailType.PHONE); + appropriateSupplier = () -> buildPhoneContactDetail(request, personDto); + + } else { + appropriatePredicate = buildPredicateFor(PersonContactDetailType.EMAIL); + appropriateSupplier = () -> buildEmailContactDetail(request, personDto); + } + + Optional alreadyPresentContactDetail = personDto.getPersonContactDetails() + .stream() + .filter(appropriatePredicate) + .filter(contactDetail -> request.getValue().equals(contactDetail.getContactInformation())) + .findAny(); + + if (alreadyPresentContactDetail.isPresent()) { + logger.debug("Person contact details already present nothing to do: [{}]", alreadyPresentContactDetail.get()); + } else { + PersonContactDetailDto contactDetail = appropriateSupplier.get(); + logger.debug("Person contact details not already present, therefore added: [{}]", contactDetail); + personDto.getPersonContactDetails().add(contactDetail); + } + + return Optional.empty(); + } + + private static @NotNull Predicate buildPredicateFor(PersonContactDetailType email) { + return contactDetail -> contactDetail.getPersonContactDetailType().equals(email); + } + + private PersonContactDetailDto buildGenericContactDetail(FieldPatchRequest request, PersonDto personDto) { + PersonContactDetailDto detail = new PersonContactDetailDto(); + detail.setUuid(DataHelper.createUuid()); + detail.setPerson(personDto.toReference()); + detail.setContactInformation((String) request.getValue()); + detail.setAdditionalInformation(request.getOrigin()); + + return detail; + } + + private PersonContactDetailDto buildPhoneContactDetail(FieldPatchRequest request, PersonDto personDto) { + PersonContactDetailDto detail = buildGenericContactDetail(request, personDto); + detail.setPersonContactDetailType(PersonContactDetailType.PHONE); + detail.setPhoneNumberType(PhoneNumberType.OTHER); + + return detail; + } + + private PersonContactDetailDto buildEmailContactDetail(FieldPatchRequest request, PersonDto personDto) { + PersonContactDetailDto detail = buildGenericContactDetail(request, personDto); + detail.setPersonContactDetailType(PersonContactDetailType.EMAIL); + + return detail; + } + + @Override + public Set supportedFields() { + return Stream.of(PersonContactDetailDto.PHONE_NUMBER_TYPE, PersonContactDetailDto.CONTACT_INFORMATION) + .map(suffix -> PersonDto.I18N_PREFIX + PATH_SEPARATOR + PersonDto.PERSON_CONTACT_DETAILS + PATH_SEPARATOR + suffix) + .collect(Collectors.toSet()); + } +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/mapping/impl/valuemapper/CustomizableEnumPatchMapper.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/mapping/impl/valuemapper/CustomizableEnumPatchMapper.java new file mode 100644 index 00000000000..628e0a1eb89 --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/mapping/impl/valuemapper/CustomizableEnumPatchMapper.java @@ -0,0 +1,125 @@ +package de.symeda.sormas.backend.patch.mapping.impl.valuemapper; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import javax.ejb.EJB; +import javax.enterprise.context.ApplicationScoped; + +import de.symeda.sormas.backend.customizableenum.CustomizableEnumFacadeEjb; +import org.apache.commons.collections4.CollectionUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import de.symeda.sormas.api.Language; +import de.symeda.sormas.api.customizableenum.CustomizableEnum; +import de.symeda.sormas.api.customizableenum.CustomizableEnumFacade; +import de.symeda.sormas.api.customizableenum.CustomizableEnumType; +import de.symeda.sormas.api.i18n.I18nProperties; +import de.symeda.sormas.api.i18n.I18nPropertiesRequest; +import de.symeda.sormas.api.patch.DataPatchFailureCause; +import de.symeda.sormas.api.patch.mapping.ValueMappingResult; +import de.symeda.sormas.api.patch.mapping.ValuePatchMapper; +import de.symeda.sormas.api.patch.mapping.ValuePatchRequest; +import de.symeda.sormas.backend.util.StringNormalizer; + +/** + * Allows to find the adequate value for customizable enum values. + */ +@ApplicationScoped +public class CustomizableEnumPatchMapper implements ValuePatchMapper { + + private final static Logger logger = LoggerFactory.getLogger(CustomizableEnumPatchMapper.class); + + public static final String FALLBACK_NAME = "OTHER"; + + @EJB + private CustomizableEnumFacadeEjb.CustomizableEnumFacadeEjbLocal customizableEnumFacade; + + @Override + public ValueMappingResult map(ValuePatchRequest request) { + Object value = request.getValue(); + Class targetType = request.getTargetType(); + String captionCandidate = value.toString(); + + if (!CustomizableEnum.class.isAssignableFrom(targetType)) { + throw new IllegalArgumentException(String.format("[%s] is not assignable from [%s].", value, targetType.getName())); + } + + CustomizableEnumType enumType = CustomizableEnumType.getByEnumClass((Class) targetType); + + if (enumType == null) { + throw new IllegalArgumentException(String.format("No CustomizableEnumType could be found for [%s]", targetType.getName())); + } + + return ((Optional) findCustomizableEnum(captionCandidate, enumType, request)).map(ValueMappingResult::withData).orElseGet(() -> { + logger.warn("Could not match value: [{}] to customizableEnumType: [{}]", captionCandidate, enumType); + + return ValueMappingResult.withCause(DataPatchFailureCause.NOT_PRESENT_IN_REFERENCE_DATA_LIST); + }); + } + + private Optional findCustomizableEnum(String captionCandidate, CustomizableEnumType type, ValuePatchRequest request) { + String normalizedInput = StringNormalizer.normalize(captionCandidate); + + return searchByDefaultLanguage(type, normalizedInput).or(() -> searchByLanguages(normalizedInput, type, request)).or(() -> { + if (request.isAllowFallbackValues()) { + return Optional.ofNullable(customizableEnumFacade.getEnumValue(type, null, FALLBACK_NAME)); + } + + return Optional.empty(); + }); + } + + private Optional searchByDefaultLanguage(CustomizableEnumType type, String normalizedInput) { + List enumValues = customizableEnumFacade.getEnumValues(type, null); + + return enumValues.stream().filter(enumMember -> matchByValueOrCaption(enumMember, normalizedInput)).findFirst(); + } + + public Optional searchByLanguages(String normalizedInput, CustomizableEnumType type, ValuePatchRequest request) { + + List inputLanguages = request.getInputLanguages(); + + if (CollectionUtils.isEmpty(inputLanguages)) { + inputLanguages = List.of(I18nProperties.getUserLanguage()); + } + + for (Language language : inputLanguages) { + Class targetType = type.getEnumClass(); + I18nPropertiesRequest i18nPropertiesRequest = new I18nPropertiesRequest().setTargetType(targetType) + .setResourceBundleType(I18nPropertiesRequest.ResourceBundleType.ENUMS) + .setLanguage(language); + Map resultingMap = I18nProperties.buildKeyValueDictionary(i18nPropertiesRequest); + + Optional customizableEnumOpt = resultingMap.entrySet() + .stream() + .filter(entry -> StringNormalizer.normalize(entry.getValue()).equals(normalizedInput)) + .findAny() + .map(Map.Entry::getKey) + .map(key -> customizableEnumFacade.getEnumValue(type, null, key)); + + if (customizableEnumOpt.isPresent()) { + return customizableEnumOpt; + } + } + return Optional.empty(); + } + + private static boolean matchByValueOrCaption(CustomizableEnum customizableEnum, String normalizedInput) { + return StringNormalizer.normalize(customizableEnum.getValue()).equals(normalizedInput) + || StringNormalizer.normalize(customizableEnum.getCaption()).equals(normalizedInput); + } + + @Override + public Set> getSupportedTypes() { + return Set.of(CustomizableEnum.class); + } + + @Override + public int getOrder() { + return LOW_PRECEDENCE - (ORDER_CHUNK * 2); + } +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/mapping/impl/valuemapper/DatePatchMapper.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/mapping/impl/valuemapper/DatePatchMapper.java new file mode 100644 index 00000000000..3650b435f60 --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/mapping/impl/valuemapper/DatePatchMapper.java @@ -0,0 +1,68 @@ +package de.symeda.sormas.backend.patch.mapping.impl.valuemapper; + +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Arrays; +import java.util.Date; +import java.util.List; +import java.util.Set; + +import javax.enterprise.context.ApplicationScoped; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import de.symeda.sormas.api.patch.DataPatchFailureCause; +import de.symeda.sormas.api.patch.mapping.ValueMappingResult; +import de.symeda.sormas.api.patch.mapping.ValuePatchMapper; +import de.symeda.sormas.api.patch.mapping.ValuePatchRequest; + +@ApplicationScoped +public class DatePatchMapper implements ValuePatchMapper { + + private static final Logger logger = LoggerFactory.getLogger(DatePatchMapper.class); + + private static final Set> SUPPORTED_TYPES = Set.of(Date.class); + + private static final List DATE_FORMATS = Arrays.asList( + "yyyy-MM-dd", // 2025-12-17 + "yyyy/MM/dd", // 2025/12/17 + "yyyy-MM-dd'T'HH:mm:ssZ", // 2025-12-17T14:30:00+0100 + "yyyy-MM-dd'T'HH:mm:ssXXX", // 2025-12-17T14:30:00+01:00 + "yyyy-MM-dd'T'HH:mm:ss", // 2025-12-17T14:30:00 + "yyyy-MM-dd'T'HH:mm", // 2025-12-17T14:30 + "yyyy/MM/dd'T'HH:mm:ss", // 2025/12/17T14:30:00 + "yyyy/MM/dd'T'HH:mm:ssXXX" // 2025/12/17T14:30:00+01:00 + ); + + @Override + public Set> getSupportedTypes() { + return SUPPORTED_TYPES; + } + + @Override + @SuppressWarnings("unchecked") + public ValueMappingResult map(ValuePatchRequest request) { + Object value = request.getValue(); + if (value == null) { + return ValueMappingResult.withData(null); + } + + String str = value.toString().trim(); + + for (String format : DATE_FORMATS) { + try { + SimpleDateFormat sdf = new SimpleDateFormat(format); + sdf.setLenient(false); + Date parsed = sdf.parse(str); + return ValueMappingResult.withData((T) parsed); + } catch (ParseException e) { + // try next format + } + } + + logger.info("DatePatchMapper: cannot parse date value [{}], expected one of formats: [{}]", str, DATE_FORMATS); + + return ValueMappingResult.withCause(DataPatchFailureCause.INVALID_VALUE_TYPE); + } +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/mapping/impl/valuemapper/EnumPatchMapper.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/mapping/impl/valuemapper/EnumPatchMapper.java new file mode 100644 index 00000000000..b63461e13f2 --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/mapping/impl/valuemapper/EnumPatchMapper.java @@ -0,0 +1,132 @@ +package de.symeda.sormas.backend.patch.mapping.impl.valuemapper; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import javax.enterprise.context.ApplicationScoped; + +import org.apache.commons.collections4.CollectionUtils; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import de.symeda.sormas.api.Language; +import de.symeda.sormas.api.i18n.I18nProperties; +import de.symeda.sormas.api.i18n.I18nPropertiesRequest; +import de.symeda.sormas.api.patch.DataPatchFailureCause; +import de.symeda.sormas.api.patch.mapping.ValueMapperDefault; +import de.symeda.sormas.api.patch.mapping.ValueMappingResult; +import de.symeda.sormas.api.patch.mapping.ValuePatchMapper; +import de.symeda.sormas.api.patch.mapping.ValuePatchRequest; +import de.symeda.sormas.backend.util.StringNormalizer; + +@ApplicationScoped +public class EnumPatchMapper implements ValuePatchMapper { + + private final static Logger logger = LoggerFactory.getLogger(EnumPatchMapper.class); + + private static final Set> SUPPORTED_TYPES = Set.of(Enum.class); + + private static final String FALLBACK_NAME = "OTHER"; + + @Override + public Set> getSupportedTypes() { + return SUPPORTED_TYPES; + } + + @Override + @SuppressWarnings({ + "unchecked", + "rawtypes" }) + public ValueMappingResult map(ValuePatchRequest request) { + Object value = request.getValue(); + Class targetType = request.getTargetType(); + + if (!Enum.class.isAssignableFrom(targetType)) { + return ValueMappingResult.withCause(DataPatchFailureCause.TECHNICAL); + } + + Class enumType = (Class) targetType; + + String normalizedInput = StringNormalizer.normalize(value.toString()); + Enum[] constants = enumType.getEnumConstants(); + + // default naive search + T enumMember = searchEnumMemberIgnoringCase(constants, normalizedInput); + if (enumMember != null) { + return ValueMappingResult.withData(enumMember); + } + + List inputLanguages = request.getInputLanguages(); + + if (CollectionUtils.isEmpty(inputLanguages)) { + inputLanguages = List.of(I18nProperties.getUserLanguage()); + } + + for (Language language : inputLanguages) { + I18nPropertiesRequest i18nPropertiesRequest = new I18nPropertiesRequest().setLanguage(language) + .setTargetType(enumType) + .setResourceBundleType(I18nPropertiesRequest.ResourceBundleType.ENUMS); + Map stringStringMap = I18nProperties.buildKeyValueDictionary(i18nPropertiesRequest); + + Optional enumMemberOpt = stringStringMap.entrySet() + .stream() + .filter(entry -> StringNormalizer.normalize(entry.getValue()).equals(normalizedInput)) + .findAny() + .map(Map.Entry::getKey) + .map(key -> searchEnumMemberIgnoringCase(constants, key)); + + if (enumMemberOpt.isPresent()) { + return ValueMappingResult.withData(enumMemberOpt.get()); + } + } + + if (!request.isAllowFallbackValues()) { + logger.info("Fallback values are not allowed: therefore nothing found for [{}] to referenceType: [{}]", normalizedInput, targetType); + return ValueMappingResult.withCause(DataPatchFailureCause.NOT_PRESENT_IN_REFERENCE_DATA_LIST); + } + + // overridden fallback + Enum annotatedDefault = findAnnotatedDefault((Class>) enumType, constants); + if (annotatedDefault != null) { + return ValueMappingResult.withData((T) annotatedDefault); + } + + // default fallback + for (Enum constant : constants) { + if (FALLBACK_NAME.equals(constant.name())) { + return ValueMappingResult.withData((T) constant); + } + } + + logger.info("Could not match value: [{}] to referenceType: [{}]", normalizedInput, targetType); + return ValueMappingResult.withCause(DataPatchFailureCause.NOT_PRESENT_IN_REFERENCE_DATA_LIST); + } + + private @Nullable T searchEnumMemberIgnoringCase(Enum[] constants, String normalizedInput) { + for (Enum constant : constants) { + if (constant.name().equalsIgnoreCase(normalizedInput)) { + return (T) constant; + } + } + return null; + } + + private Enum findAnnotatedDefault(Class> enumType, Enum[] constants) { + for (Enum constant : constants) { + try { + Field field = enumType.getField(constant.name()); + if (field.isAnnotationPresent(ValueMapperDefault.class)) { + return constant; + } + } catch (NoSuchFieldException e) { + throw new IllegalStateException(String.format("Cannot occur for enum type [%s] and value: [%s]", enumType, constant), e); + } + } + + return null; + } +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/mapping/impl/valuemapper/PrimitivePatchMapper.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/mapping/impl/valuemapper/PrimitivePatchMapper.java new file mode 100644 index 00000000000..6e6f2073bc2 --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/mapping/impl/valuemapper/PrimitivePatchMapper.java @@ -0,0 +1,133 @@ +package de.symeda.sormas.backend.patch.mapping.impl.valuemapper; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; + +import javax.enterprise.context.ApplicationScoped; + +import org.apache.commons.collections4.CollectionUtils; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import de.symeda.sormas.api.Language; +import de.symeda.sormas.api.i18n.I18nProperties; +import de.symeda.sormas.api.patch.DataPatchFailureCause; +import de.symeda.sormas.api.patch.mapping.ValueMappingResult; +import de.symeda.sormas.api.patch.mapping.ValuePatchMapper; +import de.symeda.sormas.api.patch.mapping.ValuePatchRequest; +import de.symeda.sormas.backend.util.StringNormalizer; + +@ApplicationScoped +public class PrimitivePatchMapper implements ValuePatchMapper { + + private final static Logger logger = LoggerFactory.getLogger(PrimitivePatchMapper.class); + + private static final Set> SUPPORTED_TYPES = Set.of( + String.class, + int.class, + Integer.class, + long.class, + Long.class, + BigDecimal.class, + double.class, + Double.class, + float.class, + Float.class, + Boolean.class, + boolean.class); + + private final Map, Function> NUMBER_TYPES_DICTIONARY = Map.of( + String.class, + Function.identity(), + + Integer.class, + Integer::valueOf, + + int.class, + Integer::parseInt, + + Long.class, + Long::valueOf, + + long.class, + Long::parseLong, + + BigDecimal.class, + BigDecimal::new, + + double.class, + Double::parseDouble, + + Double.class, + Double::valueOf, + + float.class, + Float::parseFloat, + + Float.class, + Float::valueOf); + + public static final String YES_I18N_KEY = "yes"; + + @Override + @SuppressWarnings("unchecked") + public ValueMappingResult map(ValuePatchRequest request) { + Object value = request.getValue(); + Class targetType = request.getTargetType(); + String str = value.toString().trim(); + + T result = null; + + if (targetType.equals(String.class)) { + result = (T) str; + } + + Function numberTypeFct = NUMBER_TYPES_DICTIONARY.get(targetType); + if (numberTypeFct != null) { + try { + result = (T) numberTypeFct.apply(str); + } catch (NumberFormatException e) { + logger.info("Cannot parse value [{}], expected format: [{}]", value, str, e); + return ValueMappingResult.withCause(DataPatchFailureCause.INVALID_VALUE_TYPE); + } + } + + if (targetType == Boolean.class || targetType == boolean.class) { + result = parseBoolean(request, str); + } + + if (result != null) { + return ValueMappingResult.withData(result); + } + + throw new IllegalArgumentException("PrimitiveWrapperMapper: unsupported type " + targetType.getName()); + } + + private static @NotNull T parseBoolean(ValuePatchRequest request, String str) { + T result; + List inputLanguages = request.getInputLanguages(); + + if (CollectionUtils.isEmpty(inputLanguages)) { + inputLanguages = List.of(I18nProperties.getUserLanguage()); + } + + String normalizedStr = StringNormalizer.normalize(str); + + result = (T) inputLanguages.stream() + .map(language -> I18nProperties.getString(language, YES_I18N_KEY)) + .filter(translation -> StringNormalizer.normalize(translation).equalsIgnoreCase(normalizedStr)) + .findAny() + .map(ignored -> true) + .orElseGet(() -> Boolean.valueOf(str)); + return result; + } + + @Override + public Set> getSupportedTypes() { + return SUPPORTED_TYPES; + } +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/mapping/impl/valuemapper/ReferenceDtoPatchMapper.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/mapping/impl/valuemapper/ReferenceDtoPatchMapper.java new file mode 100644 index 00000000000..52dd7bcbf2a --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/mapping/impl/valuemapper/ReferenceDtoPatchMapper.java @@ -0,0 +1,110 @@ +package de.symeda.sormas.backend.patch.mapping.impl.valuemapper; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import javax.ejb.EJB; +import javax.enterprise.context.ApplicationScoped; +import javax.inject.Inject; + +import org.apache.commons.collections4.CollectionUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import de.symeda.sormas.api.Language; +import de.symeda.sormas.api.ReferenceDto; +import de.symeda.sormas.api.i18n.I18nProperties; +import de.symeda.sormas.api.i18n.I18nPropertiesRequest; +import de.symeda.sormas.api.infrastructure.country.CountryReferenceDto; +import de.symeda.sormas.api.patch.DataPatchFailureCause; +import de.symeda.sormas.api.patch.mapping.ValueMappingResult; +import de.symeda.sormas.api.patch.mapping.ValuePatchMapper; +import de.symeda.sormas.api.patch.mapping.ValuePatchRequest; +import de.symeda.sormas.api.referencedata.ReferenceDataValueInstanceProvider; +import de.symeda.sormas.backend.infrastructure.country.CountryFacadeEjb; +import de.symeda.sormas.backend.util.StringNormalizer; + +@ApplicationScoped +public class ReferenceDtoPatchMapper implements ValuePatchMapper { + + private final static Logger logger = LoggerFactory.getLogger(ReferenceDtoPatchMapper.class); + + @Inject + private ReferenceDataValueInstanceProvider referenceDataValueInstanceProvider; + + @EJB + private CountryFacadeEjb.CountryFacadeEjbLocal countryFacade; + + @Override + public ValueMappingResult map(ValuePatchRequest request) { + Object value = request.getValue(); + Class targetType = request.getTargetType(); + String captionCandidate = value.toString(); + + if (!ReferenceDto.class.isAssignableFrom(targetType)) { + throw new IllegalArgumentException(String.format("[%s] is not assignable from [%s].", targetType, targetType.getName())); + } + + Class referenceType = targetType.asSubclass(ReferenceDto.class); + + return this. findByTranslationKey(value, targetType, request) + .or(() -> (Optional) findByCaptionMatch(captionCandidate, referenceType)) + .map(ValueMappingResult::withData) + .orElseGet(() -> { + logger.info("Could not match value: [{}] to referenceType: [{}]", captionCandidate, referenceType); + return ValueMappingResult.withCause(DataPatchFailureCause.NOT_PRESENT_IN_REFERENCE_DATA_LIST); + }); + } + + private Optional findByCaptionMatch(String captionCandidate, Class referenceType) { + return referenceDataValueInstanceProvider.getOne(captionCandidate, referenceType); + } + + private Optional findByTranslationKey(Object value, Class referenceType, ValuePatchRequest request) { + + if (!referenceType.equals(CountryReferenceDto.class)) { + return Optional.empty(); + } + + String normalizedInput = StringNormalizer.normalize(value.toString()); + + List inputLanguages = request.getInputLanguages(); + + if (CollectionUtils.isEmpty(inputLanguages)) { + inputLanguages = List.of(I18nProperties.getUserLanguage()); + } + + for (Language language : inputLanguages) { + I18nPropertiesRequest i18nPropertiesRequest = + new I18nPropertiesRequest().setResourceBundleType(I18nPropertiesRequest.ResourceBundleType.COUNTRY) + .setTargetType(referenceType) + .setLanguage(language); + Map stringStringMap = I18nProperties.buildKeyValueDictionary(i18nPropertiesRequest); + + Optional enumMemberOpt = stringStringMap.entrySet() + .stream() + .filter(entry -> StringNormalizer.normalize(entry.getValue()).equals(normalizedInput)) + .findAny() + .map(Map.Entry::getKey) + .map(key -> (T) countryFacade.getCountryByIsoCode(key)); + + if (enumMemberOpt.isPresent()) { + return enumMemberOpt; + } + } + + return Optional.empty(); + } + + @Override + public Set> getSupportedTypes() { + return Set.of(ReferenceDto.class); + } + + @Override + public int getOrder() { + return LOW_PRECEDENCE - (ORDER_CHUNK * 2); + } +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/partial_retrieval/ContactDetailsFieldValueRetriever.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/partial_retrieval/ContactDetailsFieldValueRetriever.java new file mode 100644 index 00000000000..f65766dafbf --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/partial_retrieval/ContactDetailsFieldValueRetriever.java @@ -0,0 +1,51 @@ +package de.symeda.sormas.backend.patch.partial_retrieval; + +import static de.symeda.sormas.backend.patch.PatchFieldHelper.PATH_SEPARATOR; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import javax.enterprise.context.ApplicationScoped; + +import org.apache.commons.lang3.StringUtils; + +import de.symeda.sormas.api.EntityDto; +import de.symeda.sormas.api.i18n.I18nProperties; +import de.symeda.sormas.api.patch.partial_retrieval.FieldInfo; +import de.symeda.sormas.api.person.PersonContactDetailDto; +import de.symeda.sormas.api.person.PersonContactDetailType; +import de.symeda.sormas.api.person.PersonDto; + +@ApplicationScoped +public class ContactDetailsFieldValueRetriever implements SpecificFieldValueRetriever { + + @Override + public FieldInfo getFieldInfo(String fieldName, EntityDto entityDto) { + PersonDto personDto = (PersonDto) entityDto; + + boolean isPhone = fieldName.contains(PersonContactDetailDto.PHONE_NUMBER_TYPE); + PersonContactDetailType targetType = isPhone ? PersonContactDetailType.PHONE : PersonContactDetailType.EMAIL; + + String contactValues = personDto.getPersonContactDetails() + .stream() + .filter(detail -> targetType.equals(detail.getPersonContactDetailType())) + .map(PersonContactDetailDto::getContactInformation) + .filter(StringUtils::isNotBlank) + .sorted() + .collect(Collectors.joining("; ")); + + String captionKey = isPhone ? PersonContactDetailDto.PHONE_NUMBER_TYPE : PersonContactDetailDto.CONTACT_INFORMATION; + String translatedFieldName = I18nProperties.getCaption(PersonContactDetailDto.I18N_PREFIX + PATH_SEPARATOR + captionKey, captionKey); + + return new FieldInfo().setFieldType(List.class).setFieldValue(contactValues).setTranslatedFieldName(translatedFieldName); + } + + @Override + public Set getSupportedFields() { + return Stream.of(PersonContactDetailDto.PHONE_NUMBER_TYPE, PersonContactDetailDto.CONTACT_INFORMATION) + .map(suffix -> PersonContactDetailDto.I18N_PREFIX + PATH_SEPARATOR + suffix) + .collect(Collectors.toSet()); + } +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/partial_retrieval/PartialRetrieverImpl.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/partial_retrieval/PartialRetrieverImpl.java new file mode 100644 index 00000000000..4d5c7e76e71 --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/partial_retrieval/PartialRetrieverImpl.java @@ -0,0 +1,209 @@ +package de.symeda.sormas.backend.patch.partial_retrieval; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +import javax.ejb.EJB; +import javax.enterprise.context.ApplicationScoped; +import javax.inject.Inject; +import javax.validation.constraints.NotNull; + +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import de.symeda.sormas.api.Disease; +import de.symeda.sormas.api.EntityDto; +import de.symeda.sormas.api.caze.CaseDataDto; +import de.symeda.sormas.api.i18n.I18nProperties; +import de.symeda.sormas.api.patch.partial_retrieval.*; +import de.symeda.sormas.api.utils.Tuple; +import de.symeda.sormas.api.utils.fieldvisibility.FieldVisibilityCheckers; +import de.symeda.sormas.backend.common.ConfigFacadeEjb; +import de.symeda.sormas.backend.feature.FeatureConfigurationFacadeEjb; +import de.symeda.sormas.backend.patch.*; +import de.symeda.sormas.backend.patch.alias.PathAliasHelper; + +@ApplicationScoped +public class PartialRetrieverImpl implements PartialRetriever { + + private final static Logger logger = LoggerFactory.getLogger(PartialRetrieverImpl.class); + + @Inject + private BusinessDtoFacade businessDtoFacade; + + @Inject + private PatchFieldHelper patchFieldHelper; + + @Inject + private PathAliasHelper pathAliasHelper; + + @Inject + private TypeToDisplayRegistry typeToDisplayRegistry; + + @Inject + private SpecificFieldValueRetrieverRegistry specificFieldValueRetrieverRegistry; + + @EJB + private FeatureConfigurationFacadeEjb.FeatureConfigurationFacadeEjbLocal featureConfigurationFacade; + + @EJB + private ConfigFacadeEjb.ConfigFacadeEjbLocal configFacade; + + @Override + public PartialRetrievalResponse retrievePartial(PartialRetrievalRequest request) { + logger.debug("retrievePartial: [{}]", request); + + CaseDataDto caseData = businessDtoFacade.getCaseDataDto(request.getCaseUuid()); + + Map> beanCache = new HashMap<>(); + + List>> results = + patchFieldHelper.extractFieldTuples(request.getFieldsToRetrieve(), businessDtoFacade.fetchablePrefixes()).stream().map(tuple -> { + + try { + return buildTupleImpl(tuple, caseData, beanCache); + } catch (RuntimeException e) { + logger.warn("Failure during retrieval for [{}]", tuple, e); + return Tuple.of(tuple.getFirst(), new Tuple<>((FieldInfo) null, PartialRetrievalFailureCause.TECHNICAL)); + } + + }).collect(Collectors.toList()); + + Map successes = results.stream() + .filter(tuple -> tuple.getSecond().getSecond() == null) + .collect(Collectors.toMap(Tuple::getFirst, tuple -> tuple.getSecond().getFirst())); + + Map failures = results.stream() + .filter(tuple -> tuple.getSecond().getSecond() != null) + .collect(Collectors.toMap(Tuple::getFirst, tuple -> tuple.getSecond().getSecond())); + + PartialRetrievalResponse result = new PartialRetrievalResponse().setFailuresDictionary(failures).setFieldInfoDictionary(successes); + + logger.debug("result: [{}]", result); + + return result; + } + + private Tuple> buildTupleImpl( + Tuple tuple, + CaseDataDto caseData, + Map> beanCache) { + String originalFieldName = tuple.getFirst(); + PathFailureCause pathFailureCause = tuple.getSecond(); + + Tuple unAliasedTuple = patchFieldHelper.resolveAlias(originalFieldName); + + PartialRetrievalFailureCause failureCause = Optional.ofNullable(pathFailureCause) + .map(PathFailureCause::getRelatedRetrieveFailureCause) + .or(() -> Optional.ofNullable(unAliasedTuple.getSecond()).map(PathFailureCause::getRelatedRetrieveFailureCause)) + .orElse(null); + + if (failureCause != null) { + return Tuple.of(originalFieldName, new Tuple<>(null, failureCause)); + } + + String pathWithoutAlias = unAliasedTuple.getFirst(); + String physicalPathName = pathWithoutAlias.substring(pathWithoutAlias.indexOf('.') + 1); + + String aliasPath = pathAliasHelper.toAliasPath(pathWithoutAlias); + Optional adequateBeanOpt = getAdequateBean(pathWithoutAlias, caseData, beanCache); + + if (adequateBeanOpt.isEmpty()) { + return Tuple.of(originalFieldName, new Tuple<>(null, PartialRetrievalFailureCause.ENTITY_COULD_NOT_BE_FOUND)); + } + + EntityDto adequateBean = adequateBeanOpt.orElseThrow(); + Optional specificFieldInfo = specificFieldValueRetrieverRegistry.getFieldInfo(aliasPath, adequateBean); + + if (specificFieldInfo.isPresent()) { + return Tuple.of(originalFieldName, new Tuple<>(specificFieldInfo.get(), null)); + } + + @NotNull + Tuple, Object>, PropertyAccessFailure> propertyType = + PropertyAccessor.getPropertyTypeAndValue(adequateBean, physicalPathName, getFieldVisibilityCheckers(caseData.getDisease())); + + PropertyAccessFailure propertyAccessFailure = propertyType.getSecond(); + if (propertyAccessFailure != null) { + return Tuple.of(originalFieldName, new Tuple<>(null, propertyAccessFailure.getRelatedRetrieveFailureCause())); + } + + Tuple, Object> fieldInfo = propertyType.getFirst(); + + // Some fields are translated only by there "physical-path" from root level + // example: Person.firstName has translation key "firstName", CaseData.disease has translation key "Disease" + // best effort: UI falls-back to humanized last segment of aliasPath + String translatedFieldName = Optional.ofNullable(I18nProperties.getCaption(aliasPath, null)) + .or(() -> Optional.ofNullable(I18nProperties.getCaption(physicalPathName, null))) + .orElseGet(() -> humanizeCamelCase(physicalPathName)); + + return Tuple.of( + originalFieldName, + new Tuple<>( + new FieldInfo().setFieldType(fieldInfo.getFirst()).setFieldValue(fieldInfo.getSecond()).setTranslatedFieldName(translatedFieldName), + null)); + } + + @Override + public DisplayablePartialRetrievalResponse retrievePartialForDisplay(PartialRetrievalRequest request) { + PartialRetrievalResponse partialRetrievalResponse = retrievePartial(request); + + return new DisplayablePartialRetrievalResponse().setFieldInfoDictionary( + partialRetrievalResponse.getFieldInfoDictionary().entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey, entry -> { + FieldInfo fieldInfo = entry.getValue(); + return new DisplayableFieldInfo().setTranslatedFieldName(fieldInfo.getTranslatedFieldName()) + .setTranslatedFieldValue(typeToDisplayRegistry.toDisplayValue(fieldInfo.getFieldValue())); + }))) + .setFailuresDescriptions( + partialRetrievalResponse.getFailuresDictionary() + .entrySet() + .stream() + .collect(Collectors.toMap(Map.Entry::getKey, entry -> I18nProperties.getEnumCaption(entry.getValue())))); + } + + private Optional getAdequateBean( + @NotNull String pathWithoutAlias, + @NotNull CaseDataDto caseData, + @NotNull Map> beanCache) { + + int i = pathWithoutAlias.indexOf("."); + + String prefix = StringUtils.substring(pathWithoutAlias, 0, i); + + if (CaseDataDto.I18N_PREFIX.equals(prefix)) { + return Optional.of(caseData); + } else { + return beanCache.computeIfAbsent(prefix, prefixCandidate -> { + List entityDtos = businessDtoFacade.fetchByI18nNameForDisplay(prefixCandidate, caseData); + + int entitiesSize = CollectionUtils.size(entityDtos); + + if (entitiesSize == 0) { + return Optional.empty(); + } + + if (entitiesSize != 1) { + logger.warn("Only first element is supported for now: [{}], was: [{}]", pathWithoutAlias, entitiesSize); + } + + return Optional.ofNullable(entityDtos).map(actualEntities -> actualEntities.get(0)); + }); + } + } + + static String humanizeCamelCase(String camelCase) { + String spaced = camelCase.replaceAll("([A-Z])", " $1").toLowerCase(); + return Character.toUpperCase(spaced.charAt(0)) + spaced.substring(1); + } + + private FieldVisibilityCheckers getFieldVisibilityCheckers(Disease disease) { + return FieldVisibilityCheckers.withCountry(configFacade.getCountryLocale()) + .andWithDisease(disease) + .andWithFeatureType(featureConfigurationFacade.getActiveServerFeatureConfigurations()); + } +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/partial_retrieval/SpecificFieldValueRetriever.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/partial_retrieval/SpecificFieldValueRetriever.java new file mode 100644 index 00000000000..d8701915a45 --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/partial_retrieval/SpecificFieldValueRetriever.java @@ -0,0 +1,52 @@ +package de.symeda.sormas.backend.patch.partial_retrieval; + +import de.symeda.sormas.api.EntityDto; +import de.symeda.sormas.api.patch.partial_retrieval.FieldInfo; +import de.symeda.sormas.api.utils.OrderedRegisterable; + +import javax.validation.constraints.NotNull; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; + +/** + * Allows custom implementation to retrieve field info a specific field. + * Might be required for : + * - fields that are stored as multiple fields but displayed as a single + * - Custom type + * - Enumeration + * etc. + */ +public interface SpecificFieldValueRetriever { + + /** + * Returns descriptor for a specific field for a given instance. + * + * @param fieldName + * name of the field on which info will be returned. + * @param entityDto + * instance on which the field must be retrieved. + * @return descriptor for a specific field for a given instance. + */ + FieldInfo getFieldInfo(String fieldName, EntityDto entityDto); + + /** + * Meant to be implemented by classes implementing this {@link OrderedRegisterable} contract but to be used. + * For usages prefer {@link #supports(String)}. + * + * @return types that are supported by this class. + */ + @NotNull + Set getSupportedFields(); + + /** + * Specifies if the targetType is supported by this class. + * + * @param targetFieldName + * can be a child class. + * @return true if the class will be able to perform some action with this type. + */ + default boolean supports(@NotNull String targetFieldName) { + return getSupportedFields().contains(targetFieldName); + } +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/partial_retrieval/SpecificFieldValueRetrieverRegistry.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/partial_retrieval/SpecificFieldValueRetrieverRegistry.java new file mode 100644 index 00000000000..6105ce41fa4 --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/partial_retrieval/SpecificFieldValueRetrieverRegistry.java @@ -0,0 +1,39 @@ +package de.symeda.sormas.backend.patch.partial_retrieval; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import javax.annotation.PostConstruct; +import javax.enterprise.context.ApplicationScoped; +import javax.enterprise.inject.Instance; +import javax.inject.Inject; +import javax.validation.constraints.NotNull; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import de.symeda.sormas.api.EntityDto; +import de.symeda.sormas.api.patch.partial_retrieval.FieldInfo; + +@ApplicationScoped +public class SpecificFieldValueRetrieverRegistry { + + private final static Logger logger = LoggerFactory.getLogger(SpecificFieldValueRetrieverRegistry.class); + + @Inject + private Instance instances; + + public SpecificFieldValueRetrieverRegistry() { + } + + public Optional getFieldInfo(@NotNull String fieldName, @NotNull EntityDto entityDto) { + return instances.stream() + .filter(retriever -> retriever.supports(fieldName)) + .findAny() + .map(retriever -> { + logger.debug("Field [{}] will be handled by retriever: [{}]", fieldName, retriever); + return retriever.getFieldInfo(fieldName, entityDto); + }); + } +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/partial_retrieval/TypeToDisplayRegistry.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/partial_retrieval/TypeToDisplayRegistry.java new file mode 100644 index 00000000000..63664442f07 --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/partial_retrieval/TypeToDisplayRegistry.java @@ -0,0 +1,57 @@ +package de.symeda.sormas.backend.patch.partial_retrieval; + +import java.util.List; +import java.util.stream.Collectors; + +import javax.annotation.Nullable; +import javax.annotation.PostConstruct; +import javax.enterprise.context.ApplicationScoped; +import javax.enterprise.inject.Instance; +import javax.inject.Inject; +import javax.validation.constraints.NotNull; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@ApplicationScoped +public class TypeToDisplayRegistry { + + private final static Logger logger = LoggerFactory.getLogger(TypeToDisplayRegistry.class); + public static final String EMPTY_STRING = ""; + + private List orderedInstances; + + @Inject + private Instance instances; + + public TypeToDisplayRegistry() { + } + + @PostConstruct + void init() { + orderedInstances = instances.stream().sorted().collect(Collectors.toList()); + } + + @NotNull + public String toDisplayValue(@Nullable Object value) { + if (value == null) { + logger.info("Input value was null, using default empty value"); + return EMPTY_STRING; + } + + Class valueType = value.getClass(); + TypeToDisplayValueMapper matchingMapper = orderedInstances.stream() + .filter(mapper -> mapper.supports(valueType)) + .findAny() + .orElseThrow( + (() -> new IllegalStateException( + String.format( + "No mapper found: [%s], Must not occur, default mapper is Object#toString(). Any registered mappers ? [%s]", + valueType, + orderedInstances)))); + + logger.debug("Value [{}] will be mapped with mapper: [{}]", value, matchingMapper); + + return matchingMapper.toDisplayValue(value); + } +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/partial_retrieval/TypeToDisplayValueMapper.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/partial_retrieval/TypeToDisplayValueMapper.java new file mode 100644 index 00000000000..6dd6896e0d3 --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/partial_retrieval/TypeToDisplayValueMapper.java @@ -0,0 +1,21 @@ +package de.symeda.sormas.backend.patch.partial_retrieval; + +import javax.validation.constraints.NotNull; + +import de.symeda.sormas.api.utils.OrderedRegisterable; + +/** + * Allows to specify how a specific value for a type must be "stringified" for display purposes. + */ +public interface TypeToDisplayValueMapper extends OrderedRegisterable { + + /** + * Will be used to be displayed as it to an user. + * + * @param value + * untyped value that is supported by this mapper. + * @return va + */ + String toDisplayValue(@NotNull Object value); + +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/partial_retrieval/impl/CustomizableEnumToDisplayValueMapper.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/partial_retrieval/impl/CustomizableEnumToDisplayValueMapper.java new file mode 100644 index 00000000000..cbe4e5fddab --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/partial_retrieval/impl/CustomizableEnumToDisplayValueMapper.java @@ -0,0 +1,29 @@ +package de.symeda.sormas.backend.patch.partial_retrieval.impl; + +import java.util.Set; + +import javax.enterprise.context.ApplicationScoped; + +import de.symeda.sormas.api.customizableenum.CustomizableEnum; +import de.symeda.sormas.backend.patch.partial_retrieval.TypeToDisplayValueMapper; + +@ApplicationScoped +public class CustomizableEnumToDisplayValueMapper implements TypeToDisplayValueMapper { + + public static final Set> SUPPORTED_TYPES = Set.of(CustomizableEnum.class); + + @Override + public String toDisplayValue(Object value) { + return ((CustomizableEnum) value).getCaption(); + } + + @Override + public Set> getSupportedTypes() { + return SUPPORTED_TYPES; + } + + @Override + public int getOrder() { + return HIGH_PRECEDENCE; + } +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/partial_retrieval/impl/DateToDisplayValueMapper.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/partial_retrieval/impl/DateToDisplayValueMapper.java new file mode 100644 index 00000000000..b108c0fd878 --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/partial_retrieval/impl/DateToDisplayValueMapper.java @@ -0,0 +1,30 @@ +package de.symeda.sormas.backend.patch.partial_retrieval.impl; + +import java.util.Date; +import java.util.Set; + +import javax.enterprise.context.ApplicationScoped; + +import de.symeda.sormas.api.utils.DateFormatHelper; +import de.symeda.sormas.backend.patch.partial_retrieval.TypeToDisplayValueMapper; + +@ApplicationScoped +public class DateToDisplayValueMapper implements TypeToDisplayValueMapper { + + public static final Set> SUPPORTED_TYPES = Set.of(Date.class); + + @Override + public String toDisplayValue(Object value) { + return DateFormatHelper.formatDate((Date) value); + } + + @Override + public Set> getSupportedTypes() { + return SUPPORTED_TYPES; + } + + @Override + public int getOrder() { + return HIGH_PRECEDENCE; + } +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/partial_retrieval/impl/EnumToDisplayValueMapper.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/partial_retrieval/impl/EnumToDisplayValueMapper.java new file mode 100644 index 00000000000..0cedcf2dee6 --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/partial_retrieval/impl/EnumToDisplayValueMapper.java @@ -0,0 +1,29 @@ +package de.symeda.sormas.backend.patch.partial_retrieval.impl; + +import java.util.Set; + +import javax.enterprise.context.ApplicationScoped; + +import de.symeda.sormas.api.i18n.I18nProperties; +import de.symeda.sormas.backend.patch.partial_retrieval.TypeToDisplayValueMapper; + +@ApplicationScoped +public class EnumToDisplayValueMapper implements TypeToDisplayValueMapper { + + public static final Set> SUPPORTED_TYPES = Set.of(Enum.class); + + @Override + public String toDisplayValue(Object value) { + return I18nProperties.getEnumCaption((Enum) value); + } + + @Override + public Set> getSupportedTypes() { + return SUPPORTED_TYPES; + } + + @Override + public int getOrder() { + return HIGH_PRECEDENCE; + } +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/partial_retrieval/impl/ObjectToDisplayValueMapper.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/partial_retrieval/impl/ObjectToDisplayValueMapper.java new file mode 100644 index 00000000000..7e552dffc42 --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/partial_retrieval/impl/ObjectToDisplayValueMapper.java @@ -0,0 +1,23 @@ +package de.symeda.sormas.backend.patch.partial_retrieval.impl; + +import java.util.Set; + +import javax.enterprise.context.ApplicationScoped; + +import de.symeda.sormas.backend.patch.partial_retrieval.TypeToDisplayValueMapper; + +@ApplicationScoped +public class ObjectToDisplayValueMapper implements TypeToDisplayValueMapper { + + public static final Set> SUPPORTED_TYPES = Set.of(Object.class); + + @Override + public String toDisplayValue(Object value) { + return value.toString(); + } + + @Override + public Set> getSupportedTypes() { + return SUPPORTED_TYPES; + } +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/partial_retrieval/impl/PrimitivesToDisplayValueMapper.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/partial_retrieval/impl/PrimitivesToDisplayValueMapper.java new file mode 100644 index 00000000000..58758801413 --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/partial_retrieval/impl/PrimitivesToDisplayValueMapper.java @@ -0,0 +1,63 @@ +package de.symeda.sormas.backend.patch.partial_retrieval.impl; + +import java.math.BigDecimal; +import java.util.Set; + +import javax.enterprise.context.ApplicationScoped; + +import de.symeda.sormas.api.i18n.I18nProperties; +import de.symeda.sormas.api.utils.YesNoUnknown; +import de.symeda.sormas.backend.patch.partial_retrieval.TypeToDisplayValueMapper; + +@ApplicationScoped +public class PrimitivesToDisplayValueMapper implements TypeToDisplayValueMapper { + + private static final Set> SUPPORTED_TYPES = Set.of( + String.class, + int.class, + Integer.class, + long.class, + Long.class, + BigDecimal.class, + double.class, + Double.class, + float.class, + Float.class, + Boolean.class, + boolean.class); + + @Override + public Set> getSupportedTypes() { + return SUPPORTED_TYPES; + } + + @Override + public String toDisplayValue(Object value) { + + if (value instanceof Number) { + return numToString((Number) value); + } + + if (value instanceof Boolean) { + YesNoUnknown yes = YesNoUnknown.YES; + return (Boolean) value ? I18nProperties.getEnumCaption(yes) : I18nProperties.getEnumCaption(YesNoUnknown.NO); + } + + return value.toString(); + } + + private static String numToString(Number num) { + if (num instanceof BigDecimal) { + return ((BigDecimal) num).toPlainString(); + } + if (num instanceof Double || num instanceof Float) { + return num.toString(); + } + return String.valueOf(num.longValue()); + } + + @Override + public int getOrder() { + return 0; + } +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/partial_retrieval/impl/ReferenceDtoToDisplayValueMapper.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/partial_retrieval/impl/ReferenceDtoToDisplayValueMapper.java new file mode 100644 index 00000000000..c0d3bfe3a7b --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/patch/partial_retrieval/impl/ReferenceDtoToDisplayValueMapper.java @@ -0,0 +1,29 @@ +package de.symeda.sormas.backend.patch.partial_retrieval.impl; + +import java.util.Set; + +import javax.enterprise.context.ApplicationScoped; + +import de.symeda.sormas.api.ReferenceDto; +import de.symeda.sormas.backend.patch.partial_retrieval.TypeToDisplayValueMapper; + +@ApplicationScoped +public class ReferenceDtoToDisplayValueMapper implements TypeToDisplayValueMapper { + + public static final Set> SUPPORTED_TYPES = Set.of(ReferenceDto.class); + + @Override + public String toDisplayValue(Object value) { + return ((ReferenceDto) value).getCaption(); + } + + @Override + public Set> getSupportedTypes() { + return SUPPORTED_TYPES; + } + + @Override + public int getOrder() { + return HIGH_PRECEDENCE; + } +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/survey/Survey.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/survey/Survey.java index fab0d9c144b..82687830e2f 100644 --- a/sormas-backend/src/main/java/de/symeda/sormas/backend/survey/Survey.java +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/survey/Survey.java @@ -36,11 +36,13 @@ public class Survey extends AbstractDomainObject { public static final String DISEASE = "disease"; public static final String DOCUMENT_TEMPLATE = "documentTemplate"; public static final String EMAIL_TEMPLATE = "emailTemplate"; + public static final String EXTERNAL_ID = "externalId"; private String name; private Disease disease; private DocumentTemplate documentTemplate; private DocumentTemplate emailTemplate; + private String externalId; @Column(nullable = false, length = FieldConstraints.CHARACTER_LIMIT_DEFAULT) public String getName() { @@ -77,4 +79,13 @@ public DocumentTemplate getEmailTemplate() { public void setEmailTemplate(DocumentTemplate emailTemplate) { this.emailTemplate = emailTemplate; } + + @Column(length = FieldConstraints.CHARACTER_LIMIT_DEFAULT) + public String getExternalId() { + return externalId; + } + + public void setExternalId(String externalId) { + this.externalId = externalId; + } } diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/survey/SurveyFacadeEjb.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/survey/SurveyFacadeEjb.java index 7c44a9ce7b0..88e9a63dd10 100644 --- a/sormas-backend/src/main/java/de/symeda/sormas/backend/survey/SurveyFacadeEjb.java +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/survey/SurveyFacadeEjb.java @@ -19,11 +19,7 @@ import java.io.ByteArrayInputStream; import java.io.IOException; -import java.util.ArrayList; -import java.util.Collections; -import java.util.Date; -import java.util.List; -import java.util.Properties; +import java.util.*; import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -35,26 +31,14 @@ import javax.persistence.EntityManager; import javax.persistence.PersistenceContext; import javax.persistence.Tuple; -import javax.persistence.criteria.CriteriaBuilder; -import javax.persistence.criteria.CriteriaQuery; -import javax.persistence.criteria.Order; -import javax.persistence.criteria.Path; -import javax.persistence.criteria.Predicate; -import javax.persistence.criteria.Root; -import javax.persistence.criteria.Selection; +import javax.persistence.criteria.*; import javax.validation.Valid; import javax.validation.constraints.NotNull; import de.symeda.sormas.api.Disease; import de.symeda.sormas.api.ReferenceDto; import de.symeda.sormas.api.caze.CaseDataDto; -import de.symeda.sormas.api.docgeneneration.DocumentTemplateCriteria; -import de.symeda.sormas.api.docgeneneration.DocumentTemplateDto; -import de.symeda.sormas.api.docgeneneration.DocumentTemplateEntities; -import de.symeda.sormas.api.docgeneneration.DocumentTemplateException; -import de.symeda.sormas.api.docgeneneration.DocumentVariables; -import de.symeda.sormas.api.docgeneneration.DocumentWorkflow; -import de.symeda.sormas.api.docgeneneration.RootEntityType; +import de.symeda.sormas.api.docgeneneration.*; import de.symeda.sormas.api.document.DocumentDto; import de.symeda.sormas.api.externalemail.AttachmentException; import de.symeda.sormas.api.externalemail.ExternalEmailException; @@ -62,12 +46,7 @@ import de.symeda.sormas.api.i18n.I18nProperties; import de.symeda.sormas.api.i18n.Strings; import de.symeda.sormas.api.i18n.Validations; -import de.symeda.sormas.api.survey.SurveyCriteria; -import de.symeda.sormas.api.survey.SurveyDocumentOptionsDto; -import de.symeda.sormas.api.survey.SurveyDto; -import de.symeda.sormas.api.survey.SurveyFacade; -import de.symeda.sormas.api.survey.SurveyIndexDto; -import de.symeda.sormas.api.survey.SurveyReferenceDto; +import de.symeda.sormas.api.survey.*; import de.symeda.sormas.api.user.UserRight; import de.symeda.sormas.api.utils.SortProperty; import de.symeda.sormas.api.utils.ValidationException; @@ -75,14 +54,8 @@ import de.symeda.sormas.backend.FacadeHelper; import de.symeda.sormas.backend.caze.CaseService; import de.symeda.sormas.backend.common.CriteriaBuilderHelper; -import de.symeda.sormas.backend.docgeneration.DocGenerationHelper; -import de.symeda.sormas.backend.docgeneration.DocumentTemplate; -import de.symeda.sormas.backend.docgeneration.DocumentTemplateEntitiesBuilder; -import de.symeda.sormas.backend.docgeneration.DocumentTemplateFacadeEjb; +import de.symeda.sormas.backend.docgeneration.*; import de.symeda.sormas.backend.docgeneration.DocumentTemplateFacadeEjb.DocumentTemplateFacadeEjbLocal; -import de.symeda.sormas.backend.docgeneration.DocumentTemplateService; -import de.symeda.sormas.backend.docgeneration.RootEntities; -import de.symeda.sormas.backend.docgeneration.TemplateEngine; import de.symeda.sormas.backend.document.DocumentService; import de.symeda.sormas.backend.externalemail.ExternalEmailFacadeEjb.ExternalEmailFacadeEjbLocal; import de.symeda.sormas.backend.user.UserService; @@ -212,7 +185,22 @@ public List getAllAsReference() { Root from = cq.from(Survey.class); cq.orderBy(cb.desc(from.get(Survey.NAME))); - return em.createQuery(cq).getResultList().stream().map(SurveyFacadeEjb::toReferenceDto).collect(Collectors.toList()); + return getAsStream(cq).map(SurveyFacadeEjb::toReferenceDto).collect(Collectors.toList()); + } + + @Override + public List getAllWithExternalSurveyId() { + CriteriaBuilder cb = em.getCriteriaBuilder(); + CriteriaQuery cq = cb.createQuery(Survey.class); + Root from = cq.from(Survey.class); + cq.where(cb.isNotNull(from.get(Survey.EXTERNAL_ID))); + cq.orderBy(cb.desc(from.get(Survey.ID))); + + return getAsStream(cq).map(this::toDto).collect(Collectors.toList()); + } + + private Stream getAsStream(CriteriaQuery cq) { + return em.createQuery(cq).getResultList().stream(); } @Override @@ -221,6 +209,17 @@ public SurveyDto getByUuid(String uuid) { return toDto(surveyService.getByUuid(uuid)); } + @Override + public List getByExternalIds(List externalIds) { + CriteriaBuilder cb = em.getCriteriaBuilder(); + CriteriaQuery cq = cb.createQuery(Survey.class); + Root from = cq.from(Survey.class); + cq.where(from.get(Survey.EXTERNAL_ID).in(externalIds)); + cq.orderBy(cb.desc(from.get(Survey.NAME))); + + return getAsStream(cq).map(this::toDto).collect(Collectors.toList()); + } + @Override @RightsAllowed(UserRight._SURVEY_VIEW) public long count(SurveyCriteria criteria) { @@ -415,6 +414,7 @@ private Survey fillOrBuildEntity(SurveyDto source, Survey target) { target.setName(source.getName()); target.setDisease(source.getDisease()); + target.setExternalId(source.getExternalId()); return target; } @@ -431,6 +431,7 @@ private SurveyDto toDto(Survey source) { target.setDisease(source.getDisease()); target.setDocumentTemplate(DocumentTemplateFacadeEjb.toReferenceDto(source.getDocumentTemplate())); target.setEmailTemplate(DocumentTemplateFacadeEjb.toReferenceDto(source.getEmailTemplate())); + target.setExternalId(source.getExternalId()); return target; } diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/survey/SurveyToken.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/survey/SurveyToken.java index 37f5dc97d7b..ed7b943e4ed 100644 --- a/sormas-backend/src/main/java/de/symeda/sormas/backend/survey/SurveyToken.java +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/survey/SurveyToken.java @@ -43,6 +43,7 @@ public class SurveyToken extends AbstractDomainObject { public static final String RESPONSE_RECEIVED = "responseReceived"; public static final String GENERATED_DOCUMENT = "generatedDocument"; public static final String RESPONSE_RECEIVED_DATE = "responseReceivedDate"; + public static final String EXTERNAL_RESPONDENT_ID = "externalRespondentId"; private String token; private Survey survey; @@ -52,6 +53,7 @@ public class SurveyToken extends AbstractDomainObject { private Document generatedDocument; private boolean responseReceived; private Date responseReceivedDate; + private String externalRespondentId; @Column(nullable = false, length = FieldConstraints.CHARACTER_LIMIT_SMALL) public String getToken() { @@ -124,4 +126,13 @@ public Date getResponseReceivedDate() { public void setResponseReceivedDate(Date responseReceivedDate) { this.responseReceivedDate = responseReceivedDate; } + + @Column(length = FieldConstraints.CHARACTER_LIMIT_UUID_MAX) + public String getExternalRespondentId() { + return externalRespondentId; + } + + public void setExternalRespondentId(String externalRespondentId) { + this.externalRespondentId = externalRespondentId; + } } diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/survey/SurveyTokenFacadeEjb.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/survey/SurveyTokenFacadeEjb.java index 998015d91d5..cc655137111 100644 --- a/sormas-backend/src/main/java/de/symeda/sormas/backend/survey/SurveyTokenFacadeEjb.java +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/survey/SurveyTokenFacadeEjb.java @@ -20,12 +20,15 @@ import java.nio.file.Paths; import java.util.ArrayList; import java.util.List; +import java.util.Optional; import java.util.stream.Collectors; import java.util.stream.Stream; import javax.ejb.EJB; import javax.ejb.LocalBean; import javax.ejb.Stateless; +import javax.naming.InitialContext; +import javax.naming.NamingException; import javax.persistence.EntityManager; import javax.persistence.PersistenceContext; import javax.persistence.Tuple; @@ -38,12 +41,19 @@ import javax.persistence.criteria.Selection; import javax.validation.Valid; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import de.symeda.sormas.api.survey.SurveyDto; import de.symeda.sormas.api.survey.SurveyReferenceDto; import de.symeda.sormas.api.survey.SurveyTokenCriteria; import de.symeda.sormas.api.survey.SurveyTokenDto; import de.symeda.sormas.api.survey.SurveyTokenFacade; import de.symeda.sormas.api.survey.SurveyTokenIndexDto; import de.symeda.sormas.api.survey.SurveyTokenReferenceDto; +import de.symeda.sormas.api.survey.external.ExternalSurveyProviderFacade; +import de.symeda.sormas.api.survey.external.views.ExternalSurveyView; +import de.symeda.sormas.api.systemconfiguration.SystemConfigurationValueFacade; import de.symeda.sormas.api.user.UserRight; import de.symeda.sormas.api.utils.DataHelper; import de.symeda.sormas.api.utils.SortProperty; @@ -68,6 +78,8 @@ @RightsAllowed(UserRight._SURVEY_TOKEN_VIEW) public class SurveyTokenFacadeEjb implements SurveyTokenFacade { + private final Logger logger = LoggerFactory.getLogger(getClass()); + @PersistenceContext(unitName = ModelConstants.PERSISTENCE_UNIT_NAME) private EntityManager em; @@ -89,6 +101,10 @@ public class SurveyTokenFacadeEjb implements SurveyTokenFacade { private ConfigFacadeEjb.ConfigFacadeEjbLocal configFacade; @EJB private DocumentService documentService; + @EJB + private SystemConfigurationValueFacade systemConfigurationValueFacade; + + public static final String EXTERNAL_SURVEY_PROVIDER_JNDI_KEY = "EXTERNAL_SURVEY_PROVIDER_JNDI_NAME"; private static final String SURVEY_TOKEN_IMPORT_TEMPLATE_FILE_NAME = "import_survey_tokens_template.csv"; @@ -153,7 +169,8 @@ public List getIndexList(SurveyTokenCriteria criteria, Inte joins.getGeneratedDocument().get(Document.UUID), joins.getGeneratedDocument().get(Document.NAME), joins.getGeneratedDocument().get(Document.MIME_TYPE), - root.get(SurveyToken.RESPONSE_RECEIVED_DATE)), + root.get(SurveyToken.RESPONSE_RECEIVED_DATE), + root.get(SurveyToken.EXTERNAL_RESPONDENT_ID)), // add sort properties to select sortBy(sortProperties, root, cb, cq, joins).stream()) .collect(Collectors.toList())); @@ -212,6 +229,20 @@ public SurveyTokenDto getBySurveyAndToken(SurveyReferenceDto survey, String toke return toDto(surveyTokenService.getBySurveyAndToken(survey, token)); } + @Override + public SurveyTokenDto getBySurveyExternalIdAndToken(String externalSurveyId, String token) { + SurveyDto surveyDto = surveyFacade.getByExternalId(externalSurveyId) + .orElseThrow(() -> new RuntimeException(String.format("Survey with external id: [%s] not found", externalSurveyId))); + + return toDto(surveyTokenService.getBySurveyAndToken(surveyDto.toReference(), token)); + } + + @Override + public List getBySurveyReferenceTokenTuples( + List> surveyReferenceTokenTuples) { + return surveyTokenService.getBySurveyReferenceTokenTuples(surveyReferenceTokenTuples).stream().map(this::toDto).collect(Collectors.toList()); + } + private String getImportTemplateFilePath(String baseFilename) { java.nio.file.Path exportDirectory = Paths.get(configFacade.getGeneratedFilesPath()); return exportDirectory.resolve(getImportTemplateFileName(baseFilename)).toString(); @@ -222,7 +253,12 @@ private String getImportTemplateFileName(String baseFilename) { return instanceName + "_" + baseFilename; } - private List> sortBy(List sortProperties, Root root, CriteriaBuilder cb, CriteriaQuery cq, SurveyTokenJoins joins) { + private List> sortBy( + List sortProperties, + Root root, + CriteriaBuilder cb, + CriteriaQuery cq, + SurveyTokenJoins joins) { List> selections = new ArrayList<>(); @@ -276,6 +312,7 @@ private SurveyToken fillOrBuildEntity(SurveyTokenDto source, SurveyToken target) target.setRecipientEmail(source.getRecipientEmail()); target.setResponseReceived(source.isResponseReceived()); target.setResponseReceivedDate(source.getResponseReceivedDate()); + target.setExternalRespondentId(source.getExternalRespondentId()); return target; } @@ -296,11 +333,49 @@ private SurveyTokenDto toDto(SurveyToken source) { target.setGeneratedDocument(DocumentFacadeEjb.toReferenceDto(source.getGeneratedDocument())); target.setResponseReceived(source.isResponseReceived()); target.setResponseReceivedDate(source.getResponseReceivedDate()); + target.setExternalRespondentId(source.getExternalRespondentId()); return target; } - public static SurveyTokenReferenceDto toReferenceDto(SurveyToken entity) { + @Override + public ExternalSurveyView getExternalSurveyView(String surveyTokenUuid) { + SurveyToken surveyToken = surveyTokenService.getByUuid(surveyTokenUuid); + if (surveyToken == null) { + logger.error("SurveyToken with uuid [{}] not found", surveyTokenUuid); + return null; + } + + String externalRespondentId = surveyToken.getExternalRespondentId(); + String externalSurveyId = surveyToken.getSurvey() != null ? surveyToken.getSurvey().getExternalId() : null; + + if (externalSurveyId == null || externalRespondentId == null) { + logger.error("SurveyToken with uuid [{}] had no externalSurveyId [{}] or [{}]", surveyTokenUuid, externalSurveyId, externalRespondentId); + return null; + } + + ExternalSurveyProviderFacade facade = getExternalSurveyProviderFacade(); + if (facade == null) { + logger.error("Facade not found"); + return null; + } + + return facade.getExternalSurveyView(externalSurveyId, externalRespondentId); + } + + private ExternalSurveyProviderFacade getExternalSurveyProviderFacade() { + String jndiName = Optional.ofNullable(systemConfigurationValueFacade.getValue(EXTERNAL_SURVEY_PROVIDER_JNDI_KEY)).orElseGet(() -> { + logger.info("External Survey Provider JNDI Key not found, using default"); + return "java:global/sormas-esante-adapter/NgSurveyProviderFacade"; + }); + try { + return (ExternalSurveyProviderFacade) new InitialContext().lookup(jndiName); + } catch (NamingException e) { + throw new RuntimeException("Could not look up ExternalSurveyProviderFacade via JNDI: " + jndiName, e); + } + } + + public SurveyTokenReferenceDto toReferenceDto(SurveyToken entity) { if (entity == null) { return null; diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/survey/SurveyTokenIndexDtoResultTransformer.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/survey/SurveyTokenIndexDtoResultTransformer.java index e0a469a095e..55c564b81ef 100644 --- a/sormas-backend/src/main/java/de/symeda/sormas/backend/survey/SurveyTokenIndexDtoResultTransformer.java +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/survey/SurveyTokenIndexDtoResultTransformer.java @@ -41,7 +41,8 @@ public Object transformTuple(Object[] tuple, String[] aliases) { (String) tuple[++index], (String) tuple[++index], (String) tuple[++index], - (Date) tuple[++index]); + (Date) tuple[++index], + (String) tuple[++index]); } @Override diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/survey/SurveyTokenService.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/survey/SurveyTokenService.java index 304999b89b9..d70dca4c70d 100644 --- a/sormas-backend/src/main/java/de/symeda/sormas/backend/survey/SurveyTokenService.java +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/survey/SurveyTokenService.java @@ -28,6 +28,7 @@ import de.symeda.sormas.api.survey.SurveyReferenceDto; import de.symeda.sormas.api.survey.SurveyTokenCriteria; +import de.symeda.sormas.api.utils.Tuple; import de.symeda.sormas.backend.caze.Case; import de.symeda.sormas.backend.common.BaseAdoService; import de.symeda.sormas.backend.common.CriteriaBuilderHelper; @@ -37,6 +38,8 @@ @LocalBean public class SurveyTokenService extends BaseAdoService { + public static final int MAX_SURVEY_TOKEN_RESULTS_SIZE = 1000; + public SurveyTokenService() { super(SurveyToken.class); } @@ -135,4 +138,25 @@ public SurveyToken getBySurveyAndToken(SurveyReferenceDto survey, String token) return QueryHelper.getFirstResult(em, cq); } + public List getBySurveyReferenceTokenTuples(List> surveyReferenceTokenTuples) { + CriteriaBuilder cb = em.getCriteriaBuilder(); + CriteriaQuery cq = cb.createQuery(SurveyToken.class); + Root root = cq.from(SurveyToken.class); + SurveyTokenJoins joins = new SurveyTokenJoins(root); + + cq.select(root); + + cq.where( + CriteriaBuilderHelper.or( + cb, + surveyReferenceTokenTuples.stream() + .map( + tuple -> CriteriaBuilderHelper.and( + cb, + cb.equal(joins.getSurvey().get(Survey.UUID), tuple.getFirst().getUuid()), + cb.equal(root.get(SurveyToken.TOKEN), tuple.getSecond()))) + .toArray(Predicate[]::new))); + + return QueryHelper.getResultList(em, cq, 0, MAX_SURVEY_TOKEN_RESULTS_SIZE); + } } diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/symptoms/Symptoms.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/symptoms/Symptoms.java index f9e2d629bc4..bdc6c982d53 100644 --- a/sormas-backend/src/main/java/de/symeda/sormas/backend/symptoms/Symptoms.java +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/symptoms/Symptoms.java @@ -282,6 +282,12 @@ public class Symptoms extends AbstractDomainObject { private SymptomState reoccurrence; private SymptomState overnightStayRequired; private SymptomState bloating; + private SymptomState lossOfAppetite; + private SymptomState flatulence; + private SymptomState smellyBurps; + private SymptomState coughingAttacks; + private SymptomState coughingAtNight; + private SymptomState abdominalCramps; // Malaria and Dengue-specific symptoms private SymptomState clammySkin; private SymptomState coldSkin; @@ -2488,4 +2494,58 @@ public void setFatalRisk(SymptomState fatalRisk) { this.fatalRisk = fatalRisk; } + + @Enumerated(EnumType.STRING) + public SymptomState getLossOfAppetite() { + return lossOfAppetite; + } + + public void setLossOfAppetite(SymptomState lossOfAppetite) { + this.lossOfAppetite = lossOfAppetite; + } + + @Enumerated(EnumType.STRING) + public SymptomState getFlatulence() { + return flatulence; + } + + public void setFlatulence(SymptomState flatulence) { + this.flatulence = flatulence; + } + + @Enumerated(EnumType.STRING) + public SymptomState getSmellyBurps() { + return smellyBurps; + } + + public void setSmellyBurps(SymptomState smellyBurps) { + this.smellyBurps = smellyBurps; + } + + @Enumerated(EnumType.STRING) + public SymptomState getCoughingAttacks() { + return coughingAttacks; + } + + public void setCoughingAttacks(SymptomState coughingAttacks) { + this.coughingAttacks = coughingAttacks; + } + + @Enumerated(EnumType.STRING) + public SymptomState getCoughingAtNight() { + return coughingAtNight; + } + + public void setCoughingAtNight(SymptomState coughingAtNight) { + this.coughingAtNight = coughingAtNight; + } + + @Enumerated(EnumType.STRING) + public SymptomState getAbdominalCramps() { + return abdominalCramps; + } + + public void setAbdominalCramps(SymptomState abdominalCramps) { + this.abdominalCramps = abdominalCramps; + } } diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/symptoms/SymptomsFacadeEjb.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/symptoms/SymptomsFacadeEjb.java index 6fb8cb8599a..100f4b8aeb9 100644 --- a/sormas-backend/src/main/java/de/symeda/sormas/backend/symptoms/SymptomsFacadeEjb.java +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/symptoms/SymptomsFacadeEjb.java @@ -248,6 +248,13 @@ public Symptoms fillOrBuildEntity(SymptomsDto source, Symptoms target, boolean c target.setWeightLossAmount(source.getWeightLossAmount()); target.setBloating(source.getBloating()); target.setOvernightStayRequired(source.getOvernightStayRequired()); + target.setLossOfAppetite(source.getLossOfAppetite()); + target.setFlatulence(source.getFlatulence()); + target.setSmellyBurps(source.getSmellyBurps()); + target.setCoughingAttacks(source.getCoughingAttacks()); + target.setCoughingAtNight(source.getCoughingAtNight()); + target.setAbdominalCramps(source.getAbdominalCramps()); + target.setClammySkin(source.getClammySkin()); target.setColdSkin(source.getColdSkin()); target.setEncephalitis(source.getEncephalitis()); @@ -537,6 +544,13 @@ public static SymptomsDto toSymptomsDto(Symptoms symptoms) { target.setConstipation(source.getConstipation()); target.setDysuria(source.getDysuria()); target.setEyeIrritation(source.getEyeIrritation()); + target.setLossOfAppetite(source.getLossOfAppetite()); + target.setFlatulence(source.getFlatulence()); + target.setSmellyBurps(source.getSmellyBurps()); + target.setCoughingAttacks(source.getCoughingAttacks()); + target.setCoughingAtNight(source.getCoughingAtNight()); + target.setAbdominalCramps(source.getAbdominalCramps()); + return target; } diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/systemconfiguration/SystemConfigurationValueEjb.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/systemconfiguration/SystemConfigurationValueEjb.java index 195d080bc22..f77901dd5e1 100644 --- a/sormas-backend/src/main/java/de/symeda/sormas/backend/systemconfiguration/SystemConfigurationValueEjb.java +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/systemconfiguration/SystemConfigurationValueEjb.java @@ -355,12 +355,12 @@ public void validate(@Valid final SystemConfigurationValueDto dto) throws Valida @PostConstruct public void loadData() { - LOGGER.info("Loading SystemConfiguration data into cache"); + LOGGER.debug("Loading SystemConfiguration data into cache"); configurationValuesByKey.clear(); service.getAll().forEach(value -> configurationValuesByKey.put(value.getKey(), value.getValue())); - LOGGER.info("SystemConfiguration data loaded into cache successfully"); + LOGGER.debug("SystemConfiguration data loaded into cache successfully"); } /** diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/user/KeycloakService.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/user/KeycloakService.java index 80cc8aa939d..f4ebbe1bb86 100644 --- a/sormas-backend/src/main/java/de/symeda/sormas/backend/user/KeycloakService.java +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/user/KeycloakService.java @@ -252,6 +252,7 @@ public void handleSyncUsersFromProviderEvent(@Observes SyncUsersFromProviderEven sormasUser = existingUsers.stream().filter(u -> u.getUserName().equals(userRepresentation.getUsername())).findFirst().orElse(new User()); } + logger.error("{}", userRepresentation); updateUser(sormasUser, userRepresentation); return sormasUser; diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/util/CollectorUtils.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/util/CollectorUtils.java new file mode 100644 index 00000000000..77a4a30b3b8 --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/util/CollectorUtils.java @@ -0,0 +1,40 @@ +package de.symeda.sormas.backend.util; + +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collector; + +import javax.validation.constraints.NotNull; + +public class CollectorUtils { + + private CollectorUtils() { + } + + public static Collector> toNullSafeMap( + @NotNull Function keyMapper, + @NotNull Function valueMapper) { + return Collector.of( + HashMap::new, + (m, item) -> m.put(keyMapper.apply(item), valueMapper.apply(item)), // handles nulls + (map1, map2) -> { + map1.putAll(map2); + return map1; + }); + } + + public static Collector> toOrderedNullSafeMap( + @NotNull Function keyMapper, + @NotNull Function valueMapper) { + return Collector.of( + LinkedHashMap::new, + (m, item) -> m.put(keyMapper.apply(item), valueMapper.apply(item)), // handles nulls + (map1, map2) -> { + map1.putAll(map2); + return map1; + }); + } + +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/util/InstanceProvider.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/util/InstanceProvider.java new file mode 100644 index 00000000000..ea27ee9eb22 --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/util/InstanceProvider.java @@ -0,0 +1,48 @@ +package de.symeda.sormas.backend.util; + +import javax.naming.InitialContext; +import javax.naming.NamingException; + +/** + * Equivalent of {@link de.symeda.sormas.api.FacadeProvider} for backend code. + */ +public class InstanceProvider { + + private final InitialContext ic; + + private static volatile InstanceProvider instance; + + protected InstanceProvider() { + if (instance != null) { + throw new IllegalStateException("BackendFacadeProvider instance already created"); + } + + try { + ic = new InitialContext(); + } catch (NamingException e) { + throw new RuntimeException(e.getMessage(), e); + } + } + + public static InstanceProvider getInstance() { + if (instance == null) { + synchronized (InstanceProvider.class) { + if (instance == null) { + instance = new InstanceProvider(); + } + } + } + return instance; + } + + public static T getInstanceFor(Class clazz) { + String classSimpleName = clazz.getSimpleName(); + + try { + // Use java:module for LOCAL lookup within same EJB module + return (T) getInstance().ic.lookup("java:module/" + classSimpleName); + } catch (NamingException e) { + throw new RuntimeException("Failed to lookup class: " + clazz, e); + } + } +} diff --git a/sormas-backend/src/main/java/de/symeda/sormas/backend/util/StringNormalizer.java b/sormas-backend/src/main/java/de/symeda/sormas/backend/util/StringNormalizer.java new file mode 100644 index 00000000000..664ea762a00 --- /dev/null +++ b/sormas-backend/src/main/java/de/symeda/sormas/backend/util/StringNormalizer.java @@ -0,0 +1,28 @@ +package de.symeda.sormas.backend.util; + +import javax.annotation.Nullable; + +import org.apache.commons.lang3.StringUtils; + +public class StringNormalizer { + + private StringNormalizer() { + } + + /** + * Meant to allow 'loose'-matching between strings, by unifying/removing: + * - case (to lower) + * - leading and trailing whitespaces + * - removing accents. + * + * @param value + * that must be normalized + * @return null if value is null otherwise normalized string. + */ + public static String normalize(@Nullable String value) { + if (null == value) { + return null; + } + return StringUtils.stripAccents(StringUtils.normalizeSpace(value.toLowerCase())); + } +} diff --git a/sormas-backend/src/main/resources/META-INF/glassfish-ejb-jar.xml b/sormas-backend/src/main/resources/META-INF/glassfish-ejb-jar.xml index 1590d53287f..bd79639c4d9 100644 --- a/sormas-backend/src/main/resources/META-INF/glassfish-ejb-jar.xml +++ b/sormas-backend/src/main/resources/META-INF/glassfish-ejb-jar.xml @@ -877,6 +877,11 @@ EXTERNAL_MESSAGE_DOCTOR_DECLARATION_VIEW + + EXTERNAL_MESSAGE_SURVEY_RESPONSE_VIEW + EXTERNAL_MESSAGE_SURVEY_RESPONSE_VIEW + + EXTERNAL_MESSAGE_LABORATORY_PROCESS EXTERNAL_MESSAGE_LABORATORY_PROCESS @@ -887,6 +892,12 @@ EXTERNAL_MESSAGE_DOCTOR_DECLARATION_PROCESS + + + EXTERNAL_MESSAGE_SURVEY_RESPONSE_PROCESS + EXTERNAL_MESSAGE_SURVEY_RESPONSE_PROCESS + + EXTERNAL_MESSAGE_LABORATORY_DELETE EXTERNAL_MESSAGE_LABORATORY_DELETE @@ -897,6 +908,11 @@ EXTERNAL_MESSAGE_DOCTOR_DECLARATION_DELETE + + EXTERNAL_MESSAGE_SURVEY_RESPONSE_DELETE + EXTERNAL_MESSAGE_SURVEY_RESPONSE_DELETE + + TRAVEL_ENTRY_MANAGEMENT_ACCESS TRAVEL_ENTRY_MANAGEMENT_ACCESS diff --git a/sormas-backend/src/main/resources/sql/sormas_schema.sql b/sormas-backend/src/main/resources/sql/sormas_schema.sql index 9914c2d0034..a01412d5235 100644 --- a/sormas-backend/src/main/resources/sql/sormas_schema.sql +++ b/sormas-backend/src/main/resources/sql/sormas_schema.sql @@ -15886,4 +15886,117 @@ ALTER TABLE symptoms_history ADD COLUMN IF NOT EXISTS dysuria varchar(255 ALTER TABLE symptoms_history ADD COLUMN IF NOT EXISTS eyeirritation varchar(255); INSERT INTO schema_version (version_number, comment) VALUES (628, '#13917 - Salmonellosis Symptoms: constipation, dysuria, eye irritation'); + +-- Surveys +ALTER TABLE surveys + ADD COLUMN externalid TEXT; + +-- Survey tokens +ALTER TABLE surveytokens + ADD COLUMN externalrespondentid TEXT; + +-- Surveys +ALTER TABLE symptoms + ADD COLUMN IF NOT EXISTS lossOfAppetite TEXT; +ALTER TABLE symptoms + ADD COLUMN IF NOT EXISTS flatulence TEXT; +ALTER TABLE symptoms + ADD COLUMN IF NOT EXISTS smellyBurps TEXT; +ALTER TABLE symptoms + ADD COLUMN IF NOT EXISTS coughingAttacks TEXT; +ALTER TABLE symptoms + ADD COLUMN IF NOT EXISTS coughingAtNight TEXT; +ALTER TABLE symptoms + ADD COLUMN IF NOT EXISTS abdominalCramps TEXT; + +-- ExternalMessage +ALTER TABLE externalmessage + ADD COLUMN IF NOT EXISTS additionalDataType TEXT; +ALTER TABLE externalmessage + ADD COLUMN IF NOT EXISTS additionalDataJson JSONB; + + +-- system configuration for surveys + +DO +$$ DECLARE +general_category_id bigint; + +BEGIN +-- Get GENERAL category id +-- General category should always exist +SELECT id +INTO general_category_id +FROM systemconfigurationcategory +WHERE name = 'GENERAL_CATEGORY'; + +INSERT INTO systemconfigurationvalue(config_key, config_value, value_description, category_id, value_optional, value_pattern, + value_encrypt, data_provider, validation_message, changedate, creationdate, id, + uuid) +VALUES ('NG_SUVEY_BASE_URI', null, 'i18n/infoSystemConfigurationValueDescriptionNgSurveyBaseURI', general_category_id, true, + '', false, null, + 'i18n/systemConfigurationValueInvalidValue', now(), now(), nextval('entity_seq'), generate_base32_uuid()); + + +INSERT INTO systemconfigurationvalue(config_key, config_value, value_description, category_id, value_optional, value_pattern, + value_encrypt, data_provider, validation_message, changedate, creationdate, id, + uuid) +VALUES ('NG_SUVEY_ENCRYPTED_TOKEN', null, 'i18n/infoSystemConfigurationValueDescriptionNgSurveyEncryptedToken', general_category_id, true, + '', true, null, + 'i18n/systemConfigurationValueInvalidValue', now(), now(), nextval('entity_seq'), generate_base32_uuid()); + +INSERT INTO systemconfigurationvalue(config_key, config_value, value_description, category_id, value_optional, value_pattern, + value_encrypt, data_provider, validation_message, changedate, creationdate, id, + uuid) +VALUES ('NG_SUVEY_FIELD_PREFIX', '_so', 'i18n/infoSystemConfigurationValueDescriptionNgSurveyFieldPrefix', general_category_id, true, + '', false, null, + 'i18n/systemConfigurationValueInvalidValue', now(), now(), nextval('entity_seq'), generate_base32_uuid()); + + + +INSERT INTO systemconfigurationvalue(config_key, config_value, value_description, category_id, value_optional, value_pattern, + value_encrypt, data_provider, validation_message, changedate, creationdate, id, + uuid) +VALUES ('SURVEY_PERIOD_INTERVAL_DAYS', '5', 'i18n/infoSystemConfigurationValueDescriptionSurveyPeriodIntervalDays', general_category_id, true, + '', true, null, + 'i18n/systemConfigurationValueInvalidValue', now(), now(), nextval('entity_seq'), generate_base32_uuid()); + + + +END $$ +LANGUAGE plpgsql; + +-- index to avoid full table scan when checking for survey duplicates +CREATE INDEX idx_externalmessage_report_id + ON externalmessage (reportid); + +UPDATE featureconfiguration +SET properties = '{"FETCH_MODE":false,"FORCE_AUTOMATIC_PROCESSING":true,"SURVEY_FETCH_ENABLED":true}' +where featuretype = 'EXTERNAL_MESSAGES'; + +-- user rights + +INSERT INTO userroles_userrights (userrole_id, userright) SELECT id, 'EXTERNAL_MESSAGE_SURVEY_RESPONSE_VIEW' FROM public.userroles WHERE userroles.linkeddefaultuserrole in ('ADMIN'); +INSERT INTO userroles_userrights (userrole_id, userright) SELECT id, 'EXTERNAL_MESSAGE_SURVEY_RESPONSE_PROCESS' FROM public.userroles WHERE userroles.linkeddefaultuserrole in ('ADMIN'); +INSERT INTO userroles_userrights (userrole_id, userright) SELECT id, 'EXTERNAL_MESSAGE_SURVEY_RESPONSE_DELETE' FROM public.userroles WHERE userroles.linkeddefaultuserrole in ('ADMIN'); + + +-- history tables +ALTER TABLE externalmessage_history ADD COLUMN IF NOT EXISTS additionaldatajson jsonb; +ALTER TABLE externalmessage_history ADD COLUMN IF NOT EXISTS additionaldatatype text; + +ALTER TABLE surveys_history ADD COLUMN IF NOT EXISTS externalid text; + +ALTER TABLE surveytokens_history ADD COLUMN IF NOT EXISTS externalrespondentid text; + +ALTER TABLE symptoms_history ADD COLUMN IF NOT EXISTS abdominalcramps text; +ALTER TABLE symptoms_history ADD COLUMN IF NOT EXISTS coughingatnight text; +ALTER TABLE symptoms_history ADD COLUMN IF NOT EXISTS coughingattacks text; +ALTER TABLE symptoms_history ADD COLUMN IF NOT EXISTS flatulence text; +ALTER TABLE symptoms_history ADD COLUMN IF NOT EXISTS lossofappetite text; +ALTER TABLE symptoms_history ADD COLUMN IF NOT EXISTS smellyburps text; + +INSERT INTO schema_version (version_number, comment) +VALUES (629, '#13832 - External Survey integration'); + -- *** Insert new sql commands BEFORE this line. Remember to always consider _history tables. *** diff --git a/sormas-backend/src/test/java/de/symeda/sormas/backend/AbstractBeanTest.java b/sormas-backend/src/test/java/de/symeda/sormas/backend/AbstractBeanTest.java index 13e479b053d..4eb9f9555ea 100644 --- a/sormas-backend/src/test/java/de/symeda/sormas/backend/AbstractBeanTest.java +++ b/sormas-backend/src/test/java/de/symeda/sormas/backend/AbstractBeanTest.java @@ -42,6 +42,8 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import de.hilling.junit.cdi.CdiTestJunitExtension; import de.hilling.junit.cdi.ContextControlWrapper; @@ -96,7 +98,10 @@ import de.symeda.sormas.api.infrastructure.subcontinent.SubcontinentFacade; import de.symeda.sormas.api.manualmessagelog.ManualMessageLogFacade; import de.symeda.sormas.api.outbreak.OutbreakFacade; +import de.symeda.sormas.api.patch.DataPatcher; +import de.symeda.sormas.api.patch.partial_retrieval.PartialRetriever; import de.symeda.sormas.api.person.notifier.NotifierFacade; +import de.symeda.sormas.api.referencedata.ReferenceDataValueInstanceProvider; import de.symeda.sormas.api.report.AggregateReportFacade; import de.symeda.sormas.api.report.WeeklyReportFacade; import de.symeda.sormas.api.sample.AdditionalTestFacade; @@ -216,6 +221,8 @@ import de.symeda.sormas.backend.manualmessagelog.ManualMessageLogFacadeEjb.ManualMessageLogFacadeEjbLocal; import de.symeda.sormas.backend.manualmessagelog.ManualMessageLogService; import de.symeda.sormas.backend.outbreak.OutbreakFacadeEjb.OutbreakFacadeEjbLocal; +import de.symeda.sormas.backend.patch.DataPatcherImpl; +import de.symeda.sormas.backend.patch.partial_retrieval.PartialRetrieverImpl; import de.symeda.sormas.backend.person.PersonFacadeEjb.PersonFacadeEjbLocal; import de.symeda.sormas.backend.person.PersonService; import de.symeda.sormas.backend.person.notifier.NotifierEjb; @@ -296,6 +303,8 @@ @MockitoSettings(strictness = Strictness.LENIENT) public abstract class AbstractBeanTest { + protected final Logger logger = LoggerFactory.getLogger(getClass()); + protected final TestDataCreator creator = new TestDataCreator(this); protected UserDto nationalAdmin; @@ -1147,4 +1156,33 @@ public NotifierFacade getNotifierFacade() { public NotifierService getNotifierService() { return getBean(NotifierService.class); } + + public DataPatcher getCaseDataPatcher() { + return getBean(DataPatcherImpl.class); + } + + public PartialRetriever getPartialRetriever() { + return getBean(PartialRetrieverImpl.class); + } + + public ReferenceDataValueInstanceProvider getReferenceDataValueInstanceProvider() { + return getBean(ReferenceDataValueInstanceProviderImpl.class); + } + + /** + * The context of the {@link AbstractBeanTest} does not possess a proper (Initial)Context, therefore if you want to ma + * + * @param beanClass + * must be used with the exact class to work. + * @param instance + * actual instance that will be returned by the context. + */ + public void registerBeanForLookup(Class beanClass, S instance) { + String classSimpleName = beanClass.getSimpleName(); + try { + when(MockProducer.initialContext.lookup("java:module/" + classSimpleName)).thenReturn(instance); + } catch (NamingException e) { + throw new RuntimeException(e); + } + } } diff --git a/sormas-backend/src/test/java/de/symeda/sormas/backend/AbstractUnitTest.java b/sormas-backend/src/test/java/de/symeda/sormas/backend/AbstractUnitTest.java new file mode 100644 index 00000000000..dd7f506060c --- /dev/null +++ b/sormas-backend/src/test/java/de/symeda/sormas/backend/AbstractUnitTest.java @@ -0,0 +1,15 @@ +package de.symeda.sormas.backend; + +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Can be used to write UNIT tests to test a class in an isolated manner. + */ +@ExtendWith(MockitoExtension.class) +public class AbstractUnitTest { + + protected final Logger logger = LoggerFactory.getLogger(getClass()); +} diff --git a/sormas-backend/src/test/java/de/symeda/sormas/backend/ArchitectureTest.java b/sormas-backend/src/test/java/de/symeda/sormas/backend/ArchitectureTest.java index b41bddbd403..b0fffdf2d20 100644 --- a/sormas-backend/src/test/java/de/symeda/sormas/backend/ArchitectureTest.java +++ b/sormas-backend/src/test/java/de/symeda/sormas/backend/ArchitectureTest.java @@ -1,9 +1,6 @@ package de.symeda.sormas.backend; -import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; -import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.fields; -import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.methods; -import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*; import java.util.Arrays; import java.util.Collections; @@ -288,7 +285,13 @@ public void testExternalMessageFacadeEjbAuthorization(JavaClasses classes) { assertFacadeEjbAnnotated( ExternalMessageFacadeEjb.class, AuthMode.CLASS_ONLY, - Arrays.asList("getExternalMessagesAdapterVersion", "fetchAndSaveExternalMessages", "bulkAssignExternalMessages", "delete"), + Arrays.asList( + "getExternalMessagesAdapterVersion", + "fetchAndSaveExternalMessages", + "bulkAssignExternalMessages", + "delete", + "overwriteSurveyResponse", + "saveAndProcessSurveyResponses"), classes); } diff --git a/sormas-backend/src/test/java/de/symeda/sormas/backend/MockProducer.java b/sormas-backend/src/test/java/de/symeda/sormas/backend/MockProducer.java index c53190ba024..4cf846e5fd5 100644 --- a/sormas-backend/src/test/java/de/symeda/sormas/backend/MockProducer.java +++ b/sormas-backend/src/test/java/de/symeda/sormas/backend/MockProducer.java @@ -66,7 +66,7 @@ public class MockProducer implements InitialContextFactory { public static final String TMP_PATH = "target/tmp"; - private static InitialContext initialContext = mock(InitialContext.class); + static InitialContext initialContext = mock(InitialContext.class); private static SessionContext sessionContext = mock(SessionContext.class, withSettings().lenient()); private static Principal principal = mock(Principal.class, withSettings().lenient()); private static Topic topic = mock(Topic.class); diff --git a/sormas-backend/src/test/java/de/symeda/sormas/backend/externalmessage/survey/AutomaticSurveyResponseProcessorTest.java b/sormas-backend/src/test/java/de/symeda/sormas/backend/externalmessage/survey/AutomaticSurveyResponseProcessorTest.java new file mode 100644 index 00000000000..fe99f9cbb0c --- /dev/null +++ b/sormas-backend/src/test/java/de/symeda/sormas/backend/externalmessage/survey/AutomaticSurveyResponseProcessorTest.java @@ -0,0 +1,450 @@ +package de.symeda.sormas.backend.externalmessage.survey; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentCaptor.forClass; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.util.Date; +import java.util.List; +import java.util.Map; + +import de.symeda.sormas.backend.survey.SurveyFacadeEjb; +import de.symeda.sormas.backend.survey.SurveyTokenFacadeEjb; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import de.symeda.sormas.api.Language; +import de.symeda.sormas.api.caze.CaseReferenceDto; +import de.symeda.sormas.api.externalmessage.ExternalMessageDto; +import de.symeda.sormas.api.externalmessage.ExternalMessageStatus; +import de.symeda.sormas.api.externalmessage.survey.ExternalMessageSurveyResponseRequest; +import de.symeda.sormas.api.externalmessage.survey.ExternalMessageSurveyResponseResult; +import de.symeda.sormas.api.externalmessage.survey.ExternalMessageSurveyResponseWrapper; +import de.symeda.sormas.api.externalmessage.survey.ExternalSurveyResponseData; +import de.symeda.sormas.api.patch.*; +import de.symeda.sormas.api.survey.SurveyDto; +import de.symeda.sormas.api.survey.SurveyFacade; +import de.symeda.sormas.api.survey.SurveyTokenDto; +import de.symeda.sormas.api.survey.SurveyTokenFacade; +import de.symeda.sormas.api.utils.DataHelper; +import de.symeda.sormas.api.utils.dataprocessing.ProcessingResultStatus; +import de.symeda.sormas.backend.AbstractUnitTest; + +class AutomaticSurveyResponseProcessorTest extends AbstractUnitTest { + + @Mock + private DataPatcher dataPatcher; + + @Mock + private SurveyFacadeEjb.SurveyFacadeEjbLocal surveyFacade; + + @Mock + private SurveyTokenFacadeEjb.SurveyTokenFacadeEjbLocal surveyTokenFacade; + + @InjectMocks + private AutomaticSurveyResponseProcessor victim; + + @Test + void processSurveyResponses_singleMessage_patchApplied_returnsDone() throws Exception { + // PREPARE + String caseUuid = DataHelper.createUuid(); + String token = "TOKEN-1"; + String externalSurveyId = "SURVEY-EXT-1"; + + ExternalMessageDto message = buildMessage(externalSurveyId, token, null); + SurveyTokenDto surveyToken = buildSurveyToken(token, caseUuid); + SurveyDto survey = buildSurvey(externalSurveyId); + + when(surveyFacade.getByExternalIds(List.of(externalSurveyId))).thenReturn(List.of(survey)); + when(surveyTokenFacade.getBySurveyReferenceTokenTuples(any())).thenReturn(List.of(surveyToken)); + when(dataPatcher.patch(any())).thenReturn(new DataPatchResponse().setApplied(true)); + + // EXECUTE + List results = victim.processSurveyResponses(List.of(message)); + + // CHECK + assertEquals(1, results.size()); + assertEquals(ProcessingResultStatus.DONE, results.get(0).getResultStatus()); + assertEquals(ExternalMessageStatus.PROCESSED, message.getStatus()); + assertNotNull(message.getSurveyResponseData().getLatest().getResult()); + } + + @Test + void processSurveyResponses_emptyMessageList_returnsEmptyResult() throws Exception { + // EXECUTE + List results = victim.processSurveyResponses(List.of()); + + // CHECK + assertEquals(0, results.size()); + verify(surveyFacade, never()).getByExternalIds(any()); + } + + @Test + void processSurveyResponses_multipleMessages_allSucceed_allReturnDone() throws Exception { + // PREPARE + String caseUuid1 = DataHelper.createUuid(); + String caseUuid2 = DataHelper.createUuid(); + SurveyTokenDto token1 = buildSurveyToken("T1", caseUuid1); + SurveyTokenDto token2 = buildSurveyToken("T2", caseUuid2); + + ExternalMessageDto msg1 = buildMessage("S1", "T1", null); + ExternalMessageDto msg2 = buildMessage("S2", "T2", null); + SurveyDto survey1 = buildSurvey("S1"); + SurveyDto survey2 = buildSurvey("S2"); + + when(surveyFacade.getByExternalIds(any())).thenReturn(List.of(survey1, survey2)); + when(surveyTokenFacade.getBySurveyReferenceTokenTuples(any())).thenReturn(List.of(token1, token2)); + when(dataPatcher.patch(any())).thenReturn(new DataPatchResponse().setApplied(true)); + + // EXECUTE + List results = victim.processSurveyResponses(List.of(msg1, msg2)); + + // CHECK + assertEquals(2, results.size()); + assertEquals(ProcessingResultStatus.DONE, results.get(0).getResultStatus()); + assertEquals(ProcessingResultStatus.DONE, results.get(1).getResultStatus()); + verify(dataPatcher, times(2)).patch(any()); + } + + @Test + void processSurveyResponses_skipIfAlreadyProcessed_resultAlreadySet_returnsCanceled() throws Exception { + // PREPARE - message already has a result + ExternalMessageSurveyResponseResult existingResult = new ExternalMessageSurveyResponseResult().setCaseUuid(DataHelper.createUuid()); + ExternalMessageDto message = buildMessage("S1", "T1", existingResult); + message.getSurveyResponseData().getLatest().getRequest().setSkipIfAlreadyProcessed(true); + + when(surveyFacade.getByExternalIds(any())).thenReturn(List.of()); + when(surveyTokenFacade.getBySurveyReferenceTokenTuples(any())).thenReturn(List.of()); + + // EXECUTE + List results = victim.processSurveyResponses(List.of(message)); + + // CHECK + assertEquals(ProcessingResultStatus.CANCELED, results.get(0).getResultStatus()); + verify(dataPatcher, never()).patch(any()); + } + + @Test + void processSurveyResponses_skipIfAlreadyProcessed_false_resultAlreadySet_reprocesses() throws Exception { + // PREPARE - message already has a result but skipIfAlreadyProcessed=false + String caseUuid = DataHelper.createUuid(); + ExternalMessageSurveyResponseResult existingResult = new ExternalMessageSurveyResponseResult().setCaseUuid(caseUuid); + ExternalMessageDto message = buildMessage("S1", "T1", existingResult); + message.getSurveyResponseData().getLatest().getRequest().setSkipIfAlreadyProcessed(false); + + SurveyTokenDto surveyToken = buildSurveyToken("T1", caseUuid); + SurveyDto survey = buildSurvey("S1"); + + when(surveyFacade.getByExternalIds(any())).thenReturn(List.of(survey)); + when(surveyTokenFacade.getBySurveyReferenceTokenTuples(any())).thenReturn(List.of(surveyToken)); + when(dataPatcher.patch(any())).thenReturn(new DataPatchResponse().setApplied(true)); + + // EXECUTE + List results = victim.processSurveyResponses(List.of(message)); + + // CHECK + assertEquals(ProcessingResultStatus.DONE, results.get(0).getResultStatus()); + verify(dataPatcher).patch(any()); + } + + @Test + void processSurveyResponses_skipIfAlreadyProcessed_resultNull_processes() throws Exception { + // PREPARE - no result yet, skipIfAlreadyProcessed=true should not block first processing + String caseUuid = DataHelper.createUuid(); + ExternalMessageDto message = buildMessage("S1", "T1", null); + message.getSurveyResponseData().getLatest().getRequest().setSkipIfAlreadyProcessed(true); + + SurveyTokenDto surveyToken = buildSurveyToken("T1", caseUuid); + SurveyDto survey = buildSurvey("S1"); + + when(surveyFacade.getByExternalIds(any())).thenReturn(List.of(survey)); + when(surveyTokenFacade.getBySurveyReferenceTokenTuples(any())).thenReturn(List.of(surveyToken)); + when(dataPatcher.patch(any())).thenReturn(new DataPatchResponse().setApplied(true)); + + // EXECUTE + List results = victim.processSurveyResponses(List.of(message)); + + // CHECK + assertEquals(ProcessingResultStatus.DONE, results.get(0).getResultStatus()); + } + + @Test + void processSurveyResponses_tokenNotInAvailableTokens_returnsCanceled() throws Exception { + // PREPARE - token in message doesn't match any fetched survey token + ExternalMessageDto message = buildMessage("S1", "TOKEN-UNKNOWN", null); + SurveyDto survey = buildSurvey("S1"); + SurveyTokenDto unrelatedToken = buildSurveyToken("DIFFERENT-TOKEN", DataHelper.createUuid()); + + when(surveyFacade.getByExternalIds(any())).thenReturn(List.of(survey)); + when(surveyTokenFacade.getBySurveyReferenceTokenTuples(any())).thenReturn(List.of(unrelatedToken)); + + // EXECUTE + List results = victim.processSurveyResponses(List.of(message)); + + // CHECK + assertEquals(ProcessingResultStatus.CANCELED, results.get(0).getResultStatus()); + verify(dataPatcher, never()).patch(any()); + } + + @Test + void processSurveyResponses_surveyNotFound_tokenListEmpty_returnsCanceled() throws Exception { + // PREPARE - survey not found → no tokens fetched + ExternalMessageDto message = buildMessage("S1", "T1", null); + + when(surveyFacade.getByExternalIds(any())).thenReturn(List.of()); + when(surveyTokenFacade.getBySurveyReferenceTokenTuples(any())).thenReturn(List.of()); + + // EXECUTE + List results = victim.processSurveyResponses(List.of(message)); + + // CHECK + assertEquals(ProcessingResultStatus.CANCELED, results.get(0).getResultStatus()); + } + + @Test + void processSurveyResponses_patchNotApplied_resultSetOnWrapper_statusCanceled() throws Exception { + // PREPARE + String caseUuid = DataHelper.createUuid(); + ExternalMessageDto message = buildMessage("S1", "T1", null); + SurveyTokenDto surveyToken = buildSurveyToken("T1", caseUuid); + SurveyDto survey = buildSurvey("S1"); + + DataPatchResponse failedResponse = new DataPatchResponse().setApplied(false); + when(surveyFacade.getByExternalIds(any())).thenReturn(List.of(survey)); + when(surveyTokenFacade.getBySurveyReferenceTokenTuples(any())).thenReturn(List.of(surveyToken)); + when(dataPatcher.patch(any())).thenReturn(failedResponse); + + // EXECUTE + List results = victim.processSurveyResponses(List.of(message)); + + // CHECK + assertEquals(ProcessingResultStatus.CANCELED, results.get(0).getResultStatus()); + // Result IS set even when patch fails — records the attempt + assertNotNull(message.getSurveyResponseData().getLatest().getResult()); + assertEquals(failedResponse, message.getSurveyResponseData().getLatest().getResult().getPatchResponse()); + // Message is NOT marked as processed when patch fails + assertEquals(ExternalMessageStatus.UNPROCESSED, message.getStatus()); + } + + @Test + void processSurveyResponses_runtimeException_exceptionCaptured_resultStatusIsNull() throws Exception { + // PREPARE + String caseUuid = DataHelper.createUuid(); + ExternalMessageDto message = buildMessage("S1", "T1", null); + SurveyTokenDto surveyToken = buildSurveyToken("T1", caseUuid); + SurveyDto survey = buildSurvey("S1"); + + RuntimeException thrown = new RuntimeException("patching failed"); + when(surveyFacade.getByExternalIds(any())).thenReturn(List.of(survey)); + when(surveyTokenFacade.getBySurveyReferenceTokenTuples(any())).thenReturn(List.of(surveyToken)); + when(dataPatcher.patch(any())).thenThrow(thrown); + + // EXECUTE + List results = victim.processSurveyResponses(List.of(message)); + + // CHECK + SurveyResponseProcessingResult result = results.get(0); + assertEquals(thrown, result.getRuntimeException()); + // BUG: resultStatus is never set in the catch block — it remains null + assertNull(result.getResultStatus()); + } + + @Test + void processSurveyResponses_runtimeException_doesNotAbortOtherMessages() throws Exception { + // PREPARE - first message throws, second should still be processed + String caseUuid2 = DataHelper.createUuid(); + ExternalMessageDto msg1 = buildMessage("S1", "T1", null); + ExternalMessageDto msg2 = buildMessage("S2", "T2", null); + SurveyTokenDto token1 = buildSurveyToken("T1", DataHelper.createUuid()); + SurveyTokenDto token2 = buildSurveyToken("T2", caseUuid2); + SurveyDto survey1 = buildSurvey("S1"); + SurveyDto survey2 = buildSurvey("S2"); + + when(surveyFacade.getByExternalIds(any())).thenReturn(List.of(survey1, survey2)); + when(surveyTokenFacade.getBySurveyReferenceTokenTuples(any())).thenReturn(List.of(token1, token2)); + when(dataPatcher.patch(any())).thenThrow(new RuntimeException("fail")).thenReturn(new DataPatchResponse().setApplied(true)); + + // EXECUTE + List results = victim.processSurveyResponses(List.of(msg1, msg2)); + + // CHECK + assertEquals(2, results.size()); + assertNotNull(results.get(0).getRuntimeException()); + assertEquals(ProcessingResultStatus.DONE, results.get(1).getResultStatus()); + } + + @Test + void processSurveyResponses_surveyTokenUpdatedWithResponseData() throws Exception { + // PREPARE + String caseUuid = DataHelper.createUuid(); + Date responseDate = new Date(); + String respondentId = "RESPONDENT-123"; + String token = "TOKEN-XYZ"; + + ExternalMessageDto message = buildMessage("S1", token, null); + message.getSurveyResponseData().getLatest().getRequest().setResponseReceivedDate(responseDate).setExternalRespondentId(respondentId); + + SurveyTokenDto surveyToken = buildSurveyToken(token, caseUuid); + SurveyDto survey = buildSurvey("S1"); + + when(surveyFacade.getByExternalIds(any())).thenReturn(List.of(survey)); + when(surveyTokenFacade.getBySurveyReferenceTokenTuples(any())).thenReturn(List.of(surveyToken)); + when(dataPatcher.patch(any())).thenReturn(new DataPatchResponse().setApplied(true)); + + // EXECUTE + victim.processSurveyResponses(List.of(message)); + + // CHECK - token was updated and saved before patch + ArgumentCaptor tokenCaptor = forClass(SurveyTokenDto.class); + verify(surveyTokenFacade).save(tokenCaptor.capture()); + SurveyTokenDto saved = tokenCaptor.getValue(); + assertEquals(true, saved.isResponseReceived()); + assertEquals(responseDate, saved.getResponseReceivedDate()); + assertEquals(respondentId, saved.getExternalRespondentId()); + } + + @Test + void processSurveyResponses_surveyTokenSavedEvenWhenPatchFails() throws Exception { + // PREPARE + ExternalMessageDto message = buildMessage("S1", "T1", null); + SurveyTokenDto surveyToken = buildSurveyToken("T1", DataHelper.createUuid()); + SurveyDto survey = buildSurvey("S1"); + + when(surveyFacade.getByExternalIds(any())).thenReturn(List.of(survey)); + when(surveyTokenFacade.getBySurveyReferenceTokenTuples(any())).thenReturn(List.of(surveyToken)); + when(dataPatcher.patch(any())).thenReturn(new DataPatchResponse().setApplied(false)); + + // EXECUTE + victim.processSurveyResponses(List.of(message)); + + // CHECK + verify(surveyTokenFacade).save(any()); + } + + @Test + void processSurveyResponses_patchRequestBuiltCorrectlyFromSurveyRequest() throws Exception { + // PREPARE + String caseUuid = DataHelper.createUuid(); + String origin = "NGSurvey"; + Map patchDict = Map.of("person.firstName", "Alice"); + + ExternalMessageDto message = buildMessage("S1", "T1", null); + message.getSurveyResponseData() + .getLatest() + .getRequest() + .setOrigin(origin) + .setPatchDictionary(patchDict) + .setReplacementStrategy(DataReplacementStrategy.ALWAYS) + .setEmptyValueBehavior(EmptyValueBehavior.REPLACE) + .setPatchedInCaseOfFailures(true) + .setAllowFallbackValues(false) + .setInputLanguages(List.of(Language.FR)); + + SurveyTokenDto surveyToken = buildSurveyToken("T1", caseUuid); + SurveyDto survey = buildSurvey("S1"); + + when(surveyFacade.getByExternalIds(any())).thenReturn(List.of(survey)); + when(surveyTokenFacade.getBySurveyReferenceTokenTuples(any())).thenReturn(List.of(surveyToken)); + when(dataPatcher.patch(any())).thenReturn(new DataPatchResponse().setApplied(true)); + + // EXECUTE + victim.processSurveyResponses(List.of(message)); + + // CHECK + ArgumentCaptor patchCaptor = forClass(CaseDataPatchRequest.class); + verify(dataPatcher).patch(patchCaptor.capture()); + CaseDataPatchRequest builtRequest = patchCaptor.getValue(); + + assertEquals(caseUuid, builtRequest.getCaseUuid()); + assertEquals(origin, builtRequest.getOrigin()); + assertEquals(patchDict, builtRequest.getPatchDictionary()); + assertEquals(DataReplacementStrategy.ALWAYS, builtRequest.getReplacementStrategy()); + assertEquals(EmptyValueBehavior.REPLACE, builtRequest.getEmptyValueBehavior()); + assertEquals(true, builtRequest.isPatchedInCaseOfFailures()); + assertEquals(false, builtRequest.isAllowFallbackValues()); + assertEquals(List.of(Language.FR), builtRequest.getInputLanguages()); + } + + @Test + void processSurveyResponses_patchApplied_resultContainsCaseUuidAndResponse() throws Exception { + // PREPARE + String caseUuid = DataHelper.createUuid(); + ExternalMessageDto message = buildMessage("S1", "T1", null); + SurveyTokenDto surveyToken = buildSurveyToken("T1", caseUuid); + SurveyDto survey = buildSurvey("S1"); + DataPatchResponse patchResponse = new DataPatchResponse().setApplied(true); + + when(surveyFacade.getByExternalIds(any())).thenReturn(List.of(survey)); + when(surveyTokenFacade.getBySurveyReferenceTokenTuples(any())).thenReturn(List.of(surveyToken)); + when(dataPatcher.patch(any())).thenReturn(patchResponse); + + // EXECUTE + victim.processSurveyResponses(List.of(message)); + + // CHECK + ExternalMessageSurveyResponseResult result = message.getSurveyResponseData().getLatest().getResult(); + assertNotNull(result); + assertEquals(caseUuid, result.getCaseUuid()); + assertEquals(patchResponse, result.getPatchResponse()); + } + + @Test + void processSurveyResponses_duplicateExternalSurveyId_differentTokens_firstMessageCanceled() throws Exception { + // PREPARE + String caseUuid1 = DataHelper.createUuid(); + String caseUuid2 = DataHelper.createUuid(); + ExternalMessageDto msg1 = buildMessage("SAME-SURVEY", "TOKEN-A", null); + ExternalMessageDto msg2 = buildMessage("SAME-SURVEY", "TOKEN-B", null); + + SurveyTokenDto tokenB = buildSurveyToken("TOKEN-B", caseUuid2); + SurveyDto survey = buildSurvey("SAME-SURVEY"); + + when(surveyFacade.getByExternalIds(List.of("SAME-SURVEY"))).thenReturn(List.of(survey)); + // The bug: only one token is passed to getBySurveyReferenceTokenTuples because the map + // stores only one token per externalSurveyId (last one wins) + when(surveyTokenFacade.getBySurveyReferenceTokenTuples(any())).thenReturn(List.of(tokenB)); + when(dataPatcher.patch(any())).thenReturn(new DataPatchResponse().setApplied(true)); + + // EXECUTE + List results = victim.processSurveyResponses(List.of(msg1, msg2)); + + // CHECK - BUG: msg1 (TOKEN-A) is canceled because TOKEN-A was lost from the map + assertEquals(ProcessingResultStatus.CANCELED, results.get(0).getResultStatus()); + assertEquals(ProcessingResultStatus.DONE, results.get(1).getResultStatus()); + } + + private static ExternalMessageDto buildMessage(String externalSurveyId, String token, ExternalMessageSurveyResponseResult result) { + ExternalMessageSurveyResponseRequest request = new ExternalMessageSurveyResponseRequest().setExternalSurveyId(externalSurveyId) + .setToken(token) + .setPatchDictionary(Map.of()) + .setExcludedPatchDictionary(Map.of()); + + ExternalMessageSurveyResponseWrapper wrapper = new ExternalMessageSurveyResponseWrapper().setRequest(request).setResult(result); + + ExternalSurveyResponseData responseData = new ExternalSurveyResponseData().setOriginal(wrapper); + + ExternalMessageDto message = ExternalMessageDto.build(); + message.setSurveyResponseData(responseData); + + return message; + } + + private static SurveyTokenDto buildSurveyToken(String token, String caseUuid) { + SurveyTokenDto dto = new SurveyTokenDto(); + dto.setUuid(DataHelper.createUuid()); + dto.setToken(token); + dto.setCaseAssignedTo(new CaseReferenceDto(caseUuid)); + return dto; + } + + private static SurveyDto buildSurvey(String externalId) { + SurveyDto survey = SurveyDto.build(); + survey.setExternalId(externalId); + return survey; + } +} diff --git a/sormas-backend/src/test/java/de/symeda/sormas/backend/patch/BusinessDtoFacadeTest.java b/sormas-backend/src/test/java/de/symeda/sormas/backend/patch/BusinessDtoFacadeTest.java new file mode 100644 index 00000000000..225b252fb08 --- /dev/null +++ b/sormas-backend/src/test/java/de/symeda/sormas/backend/patch/BusinessDtoFacadeTest.java @@ -0,0 +1,202 @@ +package de.symeda.sormas.backend.patch; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import javax.validation.Valid; +import javax.validation.constraints.NotNull; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.ArgumentMatchers; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import de.symeda.sormas.api.EntityDto; +import de.symeda.sormas.api.caze.CaseDataDto; +import de.symeda.sormas.api.immunization.ImmunizationDto; +import de.symeda.sormas.api.person.PersonDto; +import de.symeda.sormas.api.person.PersonReferenceDto; +import de.symeda.sormas.api.vaccination.VaccinationDto; +import de.symeda.sormas.backend.AbstractUnitTest; +import de.symeda.sormas.backend.caze.CaseFacadeEjb; +import de.symeda.sormas.backend.immunization.ImmunizationFacadeEjb; +import de.symeda.sormas.backend.person.PersonFacadeEjb; +import de.symeda.sormas.backend.user.UserFacadeEjb; + +class BusinessDtoFacadeTest extends AbstractUnitTest { + + @InjectMocks + private BusinessDtoFacade victim; + + @Mock + private CaseFacadeEjb.CaseFacadeEjbLocal caseFacade; + @Mock + private PersonFacadeEjb.PersonFacadeEjbLocal personFacade; + @Mock + private ImmunizationFacadeEjb.ImmunizationFacadeEjbLocal immunizationFacade; + @Mock + private UserFacadeEjb.UserFacadeEjbLocal userFacade; + + @BeforeEach + void init() throws ReflectiveOperationException { + Method initMethod = BusinessDtoFacade.class.getDeclaredMethod("init"); + initMethod.setAccessible(true); + initMethod.invoke(victim); + } + + @Test + void readAndCreateUpdatePrefixes_containSameKeys() { + assertEquals(victim.fetchablePrefixes(), victim.createUpdatePrefixes()); + } + + @Test + void allNonRootSavableDtoClasses_arePresentInBothI18nDictionaries() throws ReflectiveOperationException { + Set readPrefixes = victim.fetchablePrefixes(); + Set createUpdatePrefixes = victim.createUpdatePrefixes(); + + for (Class clazz : victim.savableDtoClasses()) { + if (CaseDataDto.class.equals(clazz)) { + continue; + } + String i18nPrefix = (String) clazz.getDeclaredField("I18N_PREFIX").get(null); + assertAll( + () -> assertTrue(readPrefixes.contains(i18nPrefix), clazz.getSimpleName() + " missing from read dictionary"), + () -> assertTrue(createUpdatePrefixes.contains(i18nPrefix), clazz.getSimpleName() + " missing from createUpdate dictionary")); + } + } + + @Test + void getCaseDataDtoNullable_returnsNull_whenNotFound() { + when(caseFacade.getByUuid("unknown")).thenReturn(null); + + assertNull(victim.getCaseDataDtoNullable("unknown")); + } + + @Test + void getCaseDataDtoNullable_returnsDto_whenFound() { + CaseDataDto expected = new CaseDataDto(); + when(caseFacade.getByUuid("uuid-1")).thenReturn(expected); + + assertSame(expected, victim.getCaseDataDtoNullable("uuid-1")); + } + + @Test + void getCaseDataDto_throwsIllegalState_whenNotFound() { + when(caseFacade.getByUuid("unknown")).thenReturn(null); + + assertThrows(IllegalStateException.class, () -> victim.getCaseDataDto("unknown")); + } + + @Test + void tryFetchByI18nNameForCreateUpdate_returnsEmpty_forUnknownPrefix() { + Optional result = victim.tryFetchByI18nNameForCreateUpdate("UnknownPrefix", new CaseDataDto()); + + assertTrue(result.isEmpty()); + } + + @Test + void tryFetchByI18nNameForCreateUpdate_returnsPersonDto_forPersonPrefix() { + PersonDto personDto = new PersonDto(); + CaseDataDto caseData = buildCaseDataWithPerson("person-uuid"); + when(personFacade.getByUuid("person-uuid")).thenReturn(personDto); + + Optional result = victim.tryFetchByI18nNameForCreateUpdate(PersonDto.I18N_PREFIX, caseData); + + assertAll(() -> assertTrue(result.isPresent()), () -> assertSame(personDto, result.get().getEntityDto())); + } + + @Test + void tryFetchByI18nNameForCreateUpdate_returnsNewImmunization_forImmunizationPrefix() { + CaseDataDto caseData = buildCaseDataWithPerson("person-uuid"); + + Optional result = victim.tryFetchByI18nNameForCreateUpdate(ImmunizationDto.I18N_PREFIX, caseData); + + assertTrue(result.isPresent()); + assertTrue(result.get().getEntityDto() instanceof ImmunizationDto); + } + + // — save(List) — + + @Test + void save_list_caseData_delegatesToCaseFacade() { + CaseDataDto caseData = new CaseDataDto(); + + victim.save(List.of(caseData)); + + verify(caseFacade).save(caseData); + } + + @Test + void save_list_personDto_delegatesToPersonFacade() { + PersonDto personDto = new PersonDto(); + + victim.save(List.of(personDto)); + + verify(personFacade).save(personDto); + } + + @Test + void save_list_immunizationDto_delegatesToImmunizationFacade() { + ImmunizationDto immunization = new ImmunizationDto(); + + victim.save(List.of(immunization)); + + verify(immunizationFacade).save(immunization); + } + + @Test + void save_list_vaccinationWithExistingImmunization_attachesVaccinationThenSavesImmunization() { + ImmunizationDto immunization = new ImmunizationDto(); + VaccinationDto vaccination = new VaccinationDto(); + + victim.save(List.of(immunization, vaccination)); + + verify(immunizationFacade).save(immunization); + assertAll(() -> assertEquals(1, immunization.getVaccinations().size()), () -> assertSame(vaccination, immunization.getVaccinations().get(0))); + } + + @Test + void save_list_vaccinationWithoutImmunization_autoCreatesImmunizationAttachesAndSaves() { + CaseDataDto caseData = buildCaseDataWithPerson("person-uuid"); + VaccinationDto vaccination = new VaccinationDto(); + + victim.save(List.of(caseData, vaccination)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ImmunizationDto.class); + verify(immunizationFacade).save(captor.capture()); + ImmunizationDto savedImmunization = captor.getValue(); + assertAll( + () -> assertEquals(1, savedImmunization.getVaccinations().size()), + () -> assertSame(vaccination, savedImmunization.getVaccinations().get(0))); + } + + @Test + void save_list_vaccinationWithoutImmunizationOrCaseData_throwsIllegalState() { + VaccinationDto vaccination = new VaccinationDto(); + + assertThrows(IllegalStateException.class, () -> victim.save(List.of(vaccination))); + } + + @Test + void save_list_vaccinationWithImmunization_doesNotCallCaseFacadeSave() { + ImmunizationDto immunization = new ImmunizationDto(); + VaccinationDto vaccination = new VaccinationDto(); + + victim.save(List.of(immunization, vaccination)); + + verify(caseFacade, never()).save(ArgumentMatchers.<@Valid @NotNull CaseDataDto> any()); + } + + private static CaseDataDto buildCaseDataWithPerson(String personUuid) { + CaseDataDto caseData = new CaseDataDto(); + caseData.setPerson(new PersonReferenceDto(personUuid)); + return caseData; + } +} diff --git a/sormas-backend/src/test/java/de/symeda/sormas/backend/patch/PatchFieldHelperTest.java b/sormas-backend/src/test/java/de/symeda/sormas/backend/patch/PatchFieldHelperTest.java new file mode 100644 index 00000000000..5f7ee1313da --- /dev/null +++ b/sormas-backend/src/test/java/de/symeda/sormas/backend/patch/PatchFieldHelperTest.java @@ -0,0 +1,116 @@ +package de.symeda.sormas.backend.patch; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.when; + +import java.util.Set; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import de.symeda.sormas.api.systemconfiguration.SystemConfigurationValueFacade; +import de.symeda.sormas.backend.AbstractUnitTest; +import de.symeda.sormas.backend.patch.alias.PathAliasHelper; +import org.mockito.Mockito; + +class PatchFieldHelperTest extends AbstractUnitTest { + + @InjectMocks + private PatchFieldHelper victim; + + @Mock + private PathAliasHelper pathAliasHelper; + + @Mock + private SystemConfigurationValueFacade systemConfigurationValueFacade; + + @Mock + private BusinessDtoFacade businessDtoFacade; + + @BeforeEach + void setUp() { + Mockito.lenient() + .when(pathAliasHelper.supportedPrefixes()) + .thenReturn(Set.of("CaseData", "Person", "Symptoms", "Immunization", "Vaccination")); + Mockito.lenient() + .when(businessDtoFacade.fetchablePrefixes()) + .thenReturn(Set.of("CaseData", "Person", "Symptoms", "Immunization", "Vaccination")); + } + + @ParameterizedTest + @ValueSource(strings = { + "Person.firstName_duplicate_", + "Person._duplicate_firstName", + "CaseData.symptoms_duplicate_.onsetDate", + "Person.firstName_duplicate_2" }) + void checkIfPathIsInvalid_duplicateMarker_returnsDuplicateField(String path) { + // EXECUTE + PathFailureCause result = victim.checkIfPathIsInvalid(path); + + // CHECK + assertEquals(PathFailureCause.DUPLICATE_FIELD, result); + } + + @Test + void checkIfPathIsInvalid_duplicateMarkerRelatedPatchCause_mapsToDataPatchFailureCause() { + // PREPARE + PathFailureCause cause = PathFailureCause.DUPLICATE_FIELD; + + // EXECUTE & CHECK + assertEquals(de.symeda.sormas.api.patch.DataPatchFailureCause.DUPLICATE_FIELD, cause.getRelatedPatchFailureCause()); + } + + @Test + void checkIfPathIsInvalid_duplicateMarkerRelatedRetrievalCause_mapsToPartialRetrievalFailureCause() { + // PREPARE + PathFailureCause cause = PathFailureCause.DUPLICATE_FIELD; + + // EXECUTE & CHECK + assertEquals( + de.symeda.sormas.api.patch.partial_retrieval.PartialRetrievalFailureCause.DUPLICATE_FIELD, + cause.getRelatedRetrieveFailureCause()); + } + + @Test + void checkIfPathIsInvalid_validPath_returnsNull() { + // PREPARE + String path = "Person.firstName"; + + // EXECUTE + PathFailureCause result = victim.checkIfPathIsInvalid(path); + + // CHECK + assertNull(result); + } + + @Test + void checkIfPathIsInvalid_noDotAndDuplicateMarker_returnsInvalidPathFormat() { + // PREPARE + String path = "firstName_duplicate_"; + + // EXECUTE + PathFailureCause result = victim.checkIfPathIsInvalid(path); + + // CHECK + assertEquals(PathFailureCause.INVALID_PATH_FORMAT, result); + } + + @ParameterizedTest + @ValueSource(strings = { + "CaseData.uuid", + "Person.deleted", + "CaseData.reportingUser", + "Immunization.relatedCase" }) + void checkIfPathIsInvalid_forbiddenField_returnsForbiddenField(String path) { + // EXECUTE + PathFailureCause result = victim.checkIfPathIsInvalid(path); + + // CHECK + assertEquals(PathFailureCause.FORBIDDEN_FIELD, result); + } +} diff --git a/sormas-backend/src/test/java/de/symeda/sormas/backend/patch/PropertyAccessorTest.java b/sormas-backend/src/test/java/de/symeda/sormas/backend/patch/PropertyAccessorTest.java new file mode 100644 index 00000000000..76514d4f25c --- /dev/null +++ b/sormas-backend/src/test/java/de/symeda/sormas/backend/patch/PropertyAccessorTest.java @@ -0,0 +1,330 @@ +package de.symeda.sormas.backend.patch; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Optional; + +import org.junit.jupiter.api.Test; + +import de.symeda.sormas.api.utils.Tuple; +import de.symeda.sormas.api.utils.fieldvisibility.FieldVisibilityCheckers; +import de.symeda.sormas.backend.AbstractUnitTest; + +public class PropertyAccessorTest extends AbstractUnitTest { + + // Both classes must be public static for Apache Commons PropertyUtils to introspect them via reflection. + // The self-referential "address" property on AddressBean is required so that for 3-segment path tests + // getPropertyType(leafValue, "address", ...) can succeed: the navigated leaf value must itself expose + // the leaf segment name as a bean property (see getNestedPropertyType implementation). + public static class PersonBean { + + private AddressBean address; + + public AddressBean getAddress() { + return address; + } + + public void setAddress(AddressBean address) { + this.address = address; + } + } + + public static class AddressBean { + + private AddressBean address; + private String street; + + public AddressBean getAddress() { + return address; + } + + public void setAddress(AddressBean address) { + this.address = address; + } + + public String getStreet() { + return street; + } + + public void setStreet(String street) { + this.street = street; + } + } + + // ---- getNestedPropertyType ---- + + @Test + void getNestedPropertyType_threeSegmentPath_returnsType() { + // PREPARE + // Build a 3-deep address chain so that navigating "address.address.address" + // reaches a non-null AddressBean leaf. + AddressBean level3 = new AddressBean(); + AddressBean level2 = new AddressBean(); + level2.setAddress(level3); + AddressBean level1 = new AddressBean(); + level1.setAddress(level2); + PersonBean person = new PersonBean(); + person.setAddress(level1); + + // EXECUTE + // "address.address.address" has 2 dots → 3 segments → triggers the nested-path branch. + // Regression: Optional.ofNullable(getNestedProperty(...)) wrapped Optional + // in a second Optional, so leafParent became Optional instead of AddressBean. + // PropertyUtils.getPropertyType(Optional, "address") then threw NoSuchMethodException, + // which was silently caught and returned FIELD_DOES_NOT_EXIST for any existing 3-segment path. + Tuple, PropertyAccessFailure> result = + PropertyAccessor.getNestedPropertyType(person, "address.address.address", FieldVisibilityCheckers.getNoop()); + + // CHECK + assertNull(result.getSecond(), "Expected no failure for existing 3-segment path, but got: " + result.getSecond()); + assertEquals(AddressBean.class, result.getFirst()); + } + + @Test + void getNestedPropertyType_threeSegmentPath_nonExistentLeaf_returnsFieldDoesNotExist() { + // PREPARE + AddressBean level3 = new AddressBean(); + AddressBean level2 = new AddressBean(); + level2.setAddress(level3); + AddressBean level1 = new AddressBean(); + level1.setAddress(level2); + PersonBean person = new PersonBean(); + person.setAddress(level1); + + // EXECUTE + Tuple, PropertyAccessFailure> result = + PropertyAccessor.getNestedPropertyType(person, "address.address.nonExistent", FieldVisibilityCheckers.getNoop()); + + // CHECK + assertEquals(PropertyAccessFailure.FIELD_DOES_NOT_EXIST, result.getSecond()); + } + + @Test + void getNestedPropertyType_twoSegmentPath_returnsType() { + // PREPARE + AddressBean address = new AddressBean(); + address.setStreet("Main St"); + PersonBean person = new PersonBean(); + person.setAddress(address); + + // EXECUTE — 1 dot → notNestedPath = true → takes the early-return branch + Tuple, PropertyAccessFailure> result = + PropertyAccessor.getNestedPropertyType(person, "address.street", FieldVisibilityCheckers.getNoop()); + + // CHECK + assertNull(result.getSecond()); + assertEquals(String.class, result.getFirst()); + } + + @Test + void getNestedPropertyType_nullBean_returnsInvalidInput() { + Tuple, PropertyAccessFailure> result = + PropertyAccessor.getNestedPropertyType(null, "address.street", FieldVisibilityCheckers.getNoop()); + + assertEquals(PropertyAccessFailure.INVALID_INPUT, result.getSecond()); + } + + @Test + void getNestedPropertyType_nullFieldName_returnsInvalidInput() { + Tuple, PropertyAccessFailure> result = + PropertyAccessor.getNestedPropertyType(new PersonBean(), null, FieldVisibilityCheckers.getNoop()); + + assertEquals(PropertyAccessFailure.INVALID_INPUT, result.getSecond()); + } + + @Test + void getNestedPropertyType_invisibleField_returnsUnsupportedField() { + // PREPARE + AddressBean address = new AddressBean(); + PersonBean person = new PersonBean(); + person.setAddress(address); + FieldVisibilityCheckers hideAll = + FieldVisibilityCheckers.withCheckers((FieldVisibilityCheckers.FieldNameBaseChecker) (type, id) -> false); + + // EXECUTE + Tuple, PropertyAccessFailure> result = + PropertyAccessor.getNestedPropertyType(person, "address.street", hideAll); + + // CHECK + assertEquals(PropertyAccessFailure.UNSUPPORTED_FIELD_FOR_DISEASE_OR_COUNTRY_OR_FEATURE, result.getSecond()); + } + + // ---- getNestedPropertyAndType ---- + + @Test + void getNestedPropertyAndType_nullBean_returnsInvalidInput() { + Tuple, Object>, PropertyAccessFailure> result = + PropertyAccessor.getNestedPropertyAndType(null, "address.street", FieldVisibilityCheckers.getNoop()); + + assertEquals(PropertyAccessFailure.INVALID_INPUT, result.getSecond()); + } + + @Test + void getNestedPropertyAndType_twoSegmentPath_returnsTypeAndValue() { + // PREPARE + AddressBean address = new AddressBean(); + address.setStreet("Baker St"); + PersonBean person = new PersonBean(); + person.setAddress(address); + + // EXECUTE + Tuple, Object>, PropertyAccessFailure> result = + PropertyAccessor.getNestedPropertyAndType(person, "address.street", FieldVisibilityCheckers.getNoop()); + + // CHECK + assertNull(result.getSecond()); + assertEquals(String.class, result.getFirst().getFirst()); + assertEquals("Baker St", result.getFirst().getSecond()); + } + + @Test + void getNestedPropertyAndType_threeSegmentPath_returnsTypeAndValue() { + // PREPARE + AddressBean level4 = new AddressBean(); + AddressBean level3 = new AddressBean(); + level3.setAddress(level4); + AddressBean level2 = new AddressBean(); + level2.setAddress(level3); + AddressBean level1 = new AddressBean(); + level1.setAddress(level2); + PersonBean person = new PersonBean(); + person.setAddress(level1); + + // EXECUTE + Tuple, Object>, PropertyAccessFailure> result = + PropertyAccessor.getNestedPropertyAndType(person, "address.address.address", FieldVisibilityCheckers.getNoop()); + + // CHECK + assertNull(result.getSecond()); + assertEquals(AddressBean.class, result.getFirst().getFirst()); + assertEquals(level4, result.getFirst().getSecond()); + } + + @Test + void getNestedPropertyAndType_threeSegmentPath_nonExistentLeaf_returnsFieldDoesNotExist() { + // PREPARE + AddressBean level3 = new AddressBean(); + AddressBean level2 = new AddressBean(); + level2.setAddress(level3); + AddressBean level1 = new AddressBean(); + level1.setAddress(level2); + PersonBean person = new PersonBean(); + person.setAddress(level1); + + // EXECUTE + Tuple, Object>, PropertyAccessFailure> result = + PropertyAccessor.getNestedPropertyAndType(person, "address.address.nonExistent", FieldVisibilityCheckers.getNoop()); + + // CHECK + assertEquals(PropertyAccessFailure.FIELD_DOES_NOT_EXIST, result.getSecond()); + } + + // ---- getPropertyTypeAndValue ---- + + @Test + void getPropertyTypeAndValue_existingField_returnsTypeAndValue() { + // PREPARE + AddressBean address = new AddressBean(); + address.setStreet("Elm Ave"); + + // EXECUTE + Tuple, Object>, PropertyAccessFailure> result = + PropertyAccessor.getPropertyTypeAndValue(address, "street", FieldVisibilityCheckers.getNoop()); + + // CHECK + assertNull(result.getSecond()); + assertEquals(String.class, result.getFirst().getFirst()); + assertEquals("Elm Ave", result.getFirst().getSecond()); + } + + @Test + void getPropertyTypeAndValue_nonExistentField_returnsFieldDoesNotExist() { + AddressBean address = new AddressBean(); + + Tuple, Object>, PropertyAccessFailure> result = + PropertyAccessor.getPropertyTypeAndValue(address, "nonExistent", FieldVisibilityCheckers.getNoop()); + + assertEquals(PropertyAccessFailure.FIELD_DOES_NOT_EXIST, result.getSecond()); + } + + @Test + void getPropertyTypeAndValue_invisibleField_returnsUnsupportedField() { + // PREPARE + AddressBean address = new AddressBean(); + FieldVisibilityCheckers hideAll = + FieldVisibilityCheckers.withCheckers((FieldVisibilityCheckers.FieldNameBaseChecker) (type, id) -> false); + + // EXECUTE + Tuple, Object>, PropertyAccessFailure> result = + PropertyAccessor.getPropertyTypeAndValue(address, "street", hideAll); + + // CHECK + assertEquals(PropertyAccessFailure.UNSUPPORTED_FIELD_FOR_DISEASE_OR_COUNTRY_OR_FEATURE, result.getSecond()); + } + + // ---- getNestedProperty ---- + + @Test + void getNestedProperty_simpleField_returnsValue() { + // PREPARE + AddressBean address = new AddressBean(); + address.setStreet("Oak Lane"); + + // EXECUTE + Optional result = PropertyAccessor.getNestedProperty(address, "street"); + + // CHECK + assertTrue(result.isPresent()); + assertEquals("Oak Lane", result.get()); + } + + @Test + void getNestedProperty_nestedField_returnsValue() { + // PREPARE + AddressBean inner = new AddressBean(); + inner.setStreet("Nested St"); + AddressBean outer = new AddressBean(); + outer.setAddress(inner); + + // EXECUTE + Optional result = PropertyAccessor.getNestedProperty(outer, "address.street"); + + // CHECK + assertTrue(result.isPresent()); + assertEquals("Nested St", result.get()); + } + + @Test + void getNestedProperty_nonExistentField_returnsEmpty() { + // NoSuchMethodException is caught internally and converted to Optional.empty() + Optional result = PropertyAccessor.getNestedProperty(new AddressBean(), "nonExistent"); + + assertFalse(result.isPresent()); + } + + // ---- setNestedProperty ---- + + @Test + void setNestedProperty_success_returnsEmpty() { + // PREPARE + AddressBean address = new AddressBean(); + + // EXECUTE + Optional result = PropertyAccessor.setNestedProperty(address, "street", "New Street"); + + // CHECK + assertFalse(result.isPresent()); + assertEquals("New Street", address.getStreet()); + } + + @Test + void setNestedProperty_nonExistentField_returnsException() { + // NoSuchMethodException is caught and returned as Optional + Optional result = PropertyAccessor.setNestedProperty(new AddressBean(), "nonExistent", "value"); + + assertTrue(result.isPresent()); + } +} diff --git a/sormas-backend/src/test/java/de/symeda/sormas/backend/patch/alias/PathAliasHelperTest.java b/sormas-backend/src/test/java/de/symeda/sormas/backend/patch/alias/PathAliasHelperTest.java new file mode 100644 index 00000000000..66be113e93e --- /dev/null +++ b/sormas-backend/src/test/java/de/symeda/sormas/backend/patch/alias/PathAliasHelperTest.java @@ -0,0 +1,206 @@ +package de.symeda.sormas.backend.patch.alias; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.junit.jupiter.api.Test; + +import de.symeda.sormas.api.utils.Tuple; +import de.symeda.sormas.backend.AbstractUnitTest; +import de.symeda.sormas.backend.patch.PathFailureCause; + +class PathAliasHelperTest extends AbstractUnitTest { + + private final PathAliasHelper victim = new PathAliasHelper(); + + @Test + void resolveAlias_noAlias_noDot_returnsOriginalPath() { + // PREPARE + String path = "some.field"; + + // EXECUTE + Tuple result = victim.resolveAlias(path); + + // CHECK + assertEquals(path, result.getFirst()); + assertNull(result.getSecond()); + } + + @Test + void resolveAlias_validAlias_caseDataPerson() { + // PREPARE + String aliasPath = "CaseData.person.firstName"; + + // EXECUTE + Tuple result = victim.resolveAlias(aliasPath); + + // CHECK + assertEquals("Person.firstName", result.getFirst()); + assertNull(result.getSecond()); + } + + @Test + void resolveAlias_validAlias_symptoms() { + // PREPARE + String aliasPath = "Symptoms.cough"; + + // EXECUTE + Tuple result = victim.resolveAlias(aliasPath); + + // CHECK + assertEquals("CaseData.symptoms.cough", result.getFirst()); + assertNull(result.getSecond()); + } + + @Test + void resolveAlias_unknownAlias_returnsOriginalPath() { + // PREPARE + String aliasPath = "UnknownAlias.field"; + + // EXECUTE + Tuple result = victim.resolveAlias(aliasPath); + + // CHECK + assertEquals(aliasPath, result.getFirst()); + assertNull(result.getSecond()); + } + + @Test + void resolveAlias_forbiddenCollision_location() { + // PREPARE + String aliasPath = "Location.region"; + + // EXECUTE + Tuple result = victim.resolveAlias(aliasPath); + + // CHECK + assertNull(result.getFirst()); + assertEquals(PathFailureCause.FORBIDDEN_NON_UNIQUE_ALIAS, result.getSecond()); + } + + @Test + void resolveAlias_forbiddenCollision_address() { + // PREPARE + String aliasPath = "Location.street"; + + // EXECUTE + Tuple result = victim.resolveAlias(aliasPath); + + // CHECK + assertNull(result.getFirst()); + assertEquals(PathFailureCause.FORBIDDEN_NON_UNIQUE_ALIAS, result.getSecond()); + } + + @Test + void resolveAlias_noDotInPath_returnsOriginal() { + // PREPARE + String path = "justAlias"; + + // EXECUTE + Tuple result = victim.resolveAlias(path); + + // CHECK + assertEquals(path, result.getFirst()); + assertNull(result.getSecond()); + } + + @Test + void resolveAlias_multipleDots_usesFirstDot() { + // PREPARE + String aliasPath = "Facility.name.somethingElse"; + + // EXECUTE + Tuple result = victim.resolveAlias(aliasPath); + + // CHECK + assertEquals("CaseData.healthFacility.name.somethingElse", result.getFirst()); + assertNull(result.getSecond()); + } + + @Test + void toAliasPath_symptomsPath_isMappedToSymptomsAlias() { + // PREPARE + String pathWithoutAlias = "CaseData.symptoms.cough"; + + // EXECUTE + String result = victim.toAliasPath(pathWithoutAlias); + + // CHECK + assertEquals("Symptoms.cough", result); + } + + @Test + void toAliasPath_healthFacility_isMappedToFacilityAlias() { + // PREPARE + String pathWithoutAlias = "CaseData.healthFacility.name"; + + // EXECUTE + String result = victim.toAliasPath(pathWithoutAlias); + + // CHECK + assertEquals("Facility.name", result); + } + + @Test + void toAliasPath_birthCountry_isMappedToCountryAlias() { + // PREPARE + String pathWithoutAlias = "Person.birthCountry.name"; + + // EXECUTE + String result = victim.toAliasPath(pathWithoutAlias); + + // CHECK + assertEquals("Country.name", result); + } + + @Test + void toAliasPath_addressSubcontinent_isMappedToSubcontinentAlias() { + // PREPARE + String pathWithoutAlias = "Person.address.subcontinent"; + + // EXECUTE + String result = victim.toAliasPath(pathWithoutAlias); + + // CHECK + assertEquals("Location.subcontinent", result); + } + + @Test + void toAliasPath_addressContinent_isMappedToContinentAlias() { + // PREPARE + String pathWithoutAlias = "Person.address.continent"; + + // EXECUTE + String result = victim.toAliasPath(pathWithoutAlias); + + // CHECK + assertEquals("Location.continent", result); + } + + @Test + void toAliasPath_locationForbiddenAliases_areMappedToLocationAlias() { + // PREPARE + String personAddressPath = "Person.address"; + String exposureLocationPath = "Exposure.location"; + + // EXECUTE + String personResult = victim.toAliasPath(personAddressPath); + String exposureResult = victim.toAliasPath(exposureLocationPath); + + // CHECK + assertEquals("Location", personResult); + assertEquals("Location", exposureResult); + } + + @Test + void toAliasPath_unknownPath_isReturnedUnchanged() { + // PREPARE + String pathWithoutAlias = "SomeUnknown.path"; + + // EXECUTE + String result = victim.toAliasPath(pathWithoutAlias); + + // CHECK + assertEquals(pathWithoutAlias, result); + } +} diff --git a/sormas-backend/src/test/java/de/symeda/sormas/backend/patch/mapping/ValueMapperRegistryTest.java b/sormas-backend/src/test/java/de/symeda/sormas/backend/patch/mapping/ValueMapperRegistryTest.java new file mode 100644 index 00000000000..b43b5a8cd78 --- /dev/null +++ b/sormas-backend/src/test/java/de/symeda/sormas/backend/patch/mapping/ValueMapperRegistryTest.java @@ -0,0 +1,165 @@ +package de.symeda.sormas.backend.patch.mapping; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.stream.Stream; + +import javax.enterprise.inject.Instance; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.testcontainers.shaded.com.fasterxml.jackson.databind.ObjectMapper; + +import de.symeda.sormas.api.patch.DataPatchFailureCause; +import de.symeda.sormas.api.patch.mapping.ValueMappingResult; +import de.symeda.sormas.api.patch.mapping.ValuePatchMapper; +import de.symeda.sormas.api.patch.mapping.ValuePatchRequest; +import de.symeda.sormas.backend.AbstractUnitTest; + +class ValueMapperRegistryTest extends AbstractUnitTest { + + @Mock + private Instance instances; + + @Mock + private ValuePatchMapper mapperA; + + @Mock + private ValuePatchMapper mapperB; + + @InjectMocks + private ValueMapperRegistry victim; + + @BeforeEach + void setUp() { + Mockito.lenient().when(instances.stream()).thenAnswer(ignored -> Stream.of(mapperA, mapperB)); + victim.init(); + } + + @Test + void map_nullValue_returnsNullResult() { + // PREPARE + ValuePatchRequest request = new ValuePatchRequest<>(); + request.setValue(null); + + // EXECUTE + ValueMappingResult result = victim.map(request); + + // CHECK + assertNull(result.getData()); + } + + @Test + void map_valueAlreadyTargetType_returnsCastValue() { + // PREPARE + ValuePatchRequest request = new ValuePatchRequest<>(); + String value = "test"; + request.setValue(value); + request.setTargetType(String.class); + + // EXECUTE + ValueMappingResult result = victim.map(request); + + // CHECK + assertSame(value, result.getData()); + } + + @Test + void map_firstSupportingMapperUsed() { + // PREPARE + ValuePatchRequest request = new ValuePatchRequest<>(); + request.setValue("42"); + request.setTargetType(Integer.class); + + when(mapperA.supports(Integer.class)).thenReturn(false); + when(mapperB.supports(Integer.class)).thenReturn(true); + ValueMappingResult mapperResult = ValueMappingResult.withData(42); + when(mapperB.map(request)).thenReturn(mapperResult); + + // EXECUTE + ValueMappingResult result = victim.map(request); + + // CHECK + assertEquals(42, result.getData()); + } + + @Test + void map_skipsNonSupportingMappers() { + // PREPARE + ValuePatchRequest request = new ValuePatchRequest<>(); + request.setValue("42"); + request.setTargetType(Integer.class); + + Mockito.lenient().when(mapperA.supports(Integer.class)).thenReturn(false); + Mockito.lenient().when(mapperB.supports(Integer.class)).thenReturn(true); + ValueMappingResult mapperResult = ValueMappingResult.withData(42); + Mockito.lenient().when(mapperB.map(request)).thenReturn(mapperResult); + + // EXECUTE + victim.map(request); + + // CHECK + verify(mapperA, never()).map(any()); + } + + @Test + void map_noSupportingMapper_returnsUnsupportedTargetType() { + // PREPARE + ValuePatchRequest request = new ValuePatchRequest<>(); + request.setValue("unsupported"); + request.setTargetType(ObjectMapper.class); + + // EXECUTE + ValueMappingResult result = victim.map(request); + + // CHECK + assertEquals(DataPatchFailureCause.UNSUPPORTED_TARGET_TYPE, result.getDataPatchFailureCause()); + } + + @Test + void map_noMappers_returnsUnsupportedTargetType() { + // PREPARE + ValuePatchRequest request = new ValuePatchRequest<>(); + request.setValue("test"); + request.setTargetType(UnsupportedOperationException.class); + + victim = new ValueMapperRegistry(instances); + victim.init(); + Mockito.lenient().when(instances.stream()).thenReturn(Stream.of()); + + // EXECUTE + ValueMappingResult result = victim.map(request); + + // CHECK + assertEquals(DataPatchFailureCause.UNSUPPORTED_TARGET_TYPE, result.getDataPatchFailureCause()); + } + + @Test + void map_mapperReturnsFailure_passesThrough() { + // PREPARE + ValuePatchRequest request = new ValuePatchRequest<>(); + request.setValue("42"); + request.setTargetType(Integer.class); + + when(mapperA.supports(Integer.class)).thenReturn(true); + ValueMappingResult failureResult = ValueMappingResult.withCause(DataPatchFailureCause.INVALID_VALUE_TYPE); + when(mapperA.map(request)).thenReturn(failureResult); + + victim.init(); + + // EXECUTE + ValueMappingResult result = victim.map(request); + + // CHECK + assertEquals(DataPatchFailureCause.INVALID_VALUE_TYPE, result.getDataPatchFailureCause()); + } +} diff --git a/sormas-backend/src/test/java/de/symeda/sormas/backend/patch/mapping/impl/equalitychecker/DatePatchingEqualityCheckerTest.java b/sormas-backend/src/test/java/de/symeda/sormas/backend/patch/mapping/impl/equalitychecker/DatePatchingEqualityCheckerTest.java new file mode 100644 index 00000000000..0f7348d9ecb --- /dev/null +++ b/sormas-backend/src/test/java/de/symeda/sormas/backend/patch/mapping/impl/equalitychecker/DatePatchingEqualityCheckerTest.java @@ -0,0 +1,118 @@ +package de.symeda.sormas.backend.patch.mapping.impl.equalitychecker; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Date; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; + +import de.symeda.sormas.api.utils.OrderedRegisterable; +import de.symeda.sormas.backend.AbstractUnitTest; + +class DatePatchingEqualityCheckerTest extends AbstractUnitTest { + + @InjectMocks + private DatePatchingEqualityChecker victim; + + @Test + void getSupportedTypes_containsDateClass() { + // PREPARE + Set> expected = Set.of(Date.class); + + // EXECUTE + Set> actual = victim.getSupportedTypes(); + + // CHECK + assertEquals(expected, actual); + } + + @Test + void getOrder_isHighPrecedence() { + assertEquals(OrderedRegisterable.HIGH_PRECEDENCE, victim.getOrder()); + } + + @Test + void areEqual_sameDateInstance_returnsTrue() { + // PREPARE + Date date = toDate(LocalDate.of(2024, 6, 15)); + + // EXECUTE & CHECK + assertTrue(victim.areEqual(date, date)); + } + + @Test + void areEqual_sameTimestamp_returnsTrue() { + // PREPARE + long millis = toDate(LocalDate.of(2024, 6, 15)).getTime(); + Date a = new Date(millis); + Date b = new Date(millis); + + // EXECUTE & CHECK + assertTrue(victim.areEqual(a, b)); + } + + @Test + void areEqual_sameDayDifferentTime_returnsTrue() { + // PREPARE + Date morning = toDate(LocalDateTime.of(2024, 6, 15, 8, 0, 0)); + Date evening = toDate(LocalDateTime.of(2024, 6, 15, 23, 59, 59)); + + // EXECUTE & CHECK + assertTrue(victim.areEqual(morning, evening)); + } + + @Test + void areEqual_differentDays_returnsFalse() { + // PREPARE + Date day1 = toDate(LocalDate.of(2024, 6, 15)); + Date day2 = toDate(LocalDate.of(2024, 6, 16)); + + // EXECUTE & CHECK + assertFalse(victim.areEqual(day1, day2)); + } + + @Test + void areEqual_differentMonths_returnsFalse() { + // PREPARE + Date june = toDate(LocalDate.of(2024, 6, 15)); + Date july = toDate(LocalDate.of(2024, 7, 15)); + + // EXECUTE & CHECK + assertFalse(victim.areEqual(june, july)); + } + + @Test + void areEqual_differentYears_returnsFalse() { + // PREPARE + Date year2023 = toDate(LocalDate.of(2023, 6, 15)); + Date year2024 = toDate(LocalDate.of(2024, 6, 15)); + + // EXECUTE & CHECK + assertFalse(victim.areEqual(year2023, year2024)); + } + + @Test + void areEqual_endOfDayAndStartOfNextDay_returnsFalse() { + // PREPARE + Date endOfDay = toDate(LocalDateTime.of(2024, 6, 15, 23, 59, 59)); + Date startOfNextDay = toDate(LocalDateTime.of(2024, 6, 16, 0, 0, 0)); + + // EXECUTE & CHECK + assertFalse(victim.areEqual(endOfDay, startOfNextDay)); + } + + private static Date toDate(LocalDate localDate) { + return Date.from(localDate.atStartOfDay(ZoneId.systemDefault()).toInstant()); + } + + private static Date toDate(LocalDateTime localDateTime) { + return Date.from(localDateTime.atZone(ZoneId.systemDefault()).toInstant()); + } +} diff --git a/sormas-backend/src/test/java/de/symeda/sormas/backend/patch/mapping/impl/equalitychecker/ObjectPatchingEqualityCheckerTest.java b/sormas-backend/src/test/java/de/symeda/sormas/backend/patch/mapping/impl/equalitychecker/ObjectPatchingEqualityCheckerTest.java new file mode 100644 index 00000000000..dc81c5f00f5 --- /dev/null +++ b/sormas-backend/src/test/java/de/symeda/sormas/backend/patch/mapping/impl/equalitychecker/ObjectPatchingEqualityCheckerTest.java @@ -0,0 +1,75 @@ +package de.symeda.sormas.backend.patch.mapping.impl.equalitychecker; + +import static de.symeda.sormas.api.utils.OrderedRegisterable.LOW_PRECEDENCE; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; + +import de.symeda.sormas.backend.AbstractUnitTest; + +class ObjectPatchingEqualityCheckerTest extends AbstractUnitTest { + + @InjectMocks + private ObjectPatchingEqualityChecker victim; + + @Test + void getSupportedTypes_containsObjectClass() { + // PREPARE + Set> expected = Set.of(Object.class); + + // EXECUTE + Set> actual = victim.getSupportedTypes(); + + // CHECK + assertEquals(expected, actual); + } + + @Test + void getOrder_isLowPrecedence() { + assertEquals(LOW_PRECEDENCE, victim.getOrder()); + } + + @Test + void areEqual_sameStringInstance_returnsTrue() { + // PREPARE + String value = "hello"; + + // EXECUTE & CHECK + assertTrue(victim.areEqual(value, value)); + } + + @Test + void areEqual_equalStrings_returnsTrue() { + // EXECUTE & CHECK + assertTrue(victim.areEqual("hello", "hello")); + } + + @Test + void areEqual_differentStrings_returnsFalse() { + // EXECUTE & CHECK + assertFalse(victim.areEqual("hello", "world")); + } + + @Test + void areEqual_equalIntegers_returnsTrue() { + // EXECUTE & CHECK + assertTrue(victim.areEqual(42, 42)); + } + + @Test + void areEqual_differentIntegers_returnsFalse() { + // EXECUTE & CHECK + assertFalse(victim.areEqual(1, 2)); + } + + @Test + void areEqual_differentTypes_returnsFalse() { + // EXECUTE & CHECK + assertFalse(victim.areEqual("42", 42)); + } +} diff --git a/sormas-backend/src/test/java/de/symeda/sormas/backend/patch/mapping/impl/fieldmapper/PersonContactDetailsFieldMapperTest.java b/sormas-backend/src/test/java/de/symeda/sormas/backend/patch/mapping/impl/fieldmapper/PersonContactDetailsFieldMapperTest.java new file mode 100644 index 00000000000..bc59ec83524 --- /dev/null +++ b/sormas-backend/src/test/java/de/symeda/sormas/backend/patch/mapping/impl/fieldmapper/PersonContactDetailsFieldMapperTest.java @@ -0,0 +1,271 @@ +package de.symeda.sormas.backend.patch.mapping.impl.fieldmapper; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; + +import de.symeda.sormas.api.patch.DataPatchFailure; +import de.symeda.sormas.api.patch.DataPatchFailureCause; +import de.symeda.sormas.api.patch.mapping.FieldPatchRequest; +import de.symeda.sormas.api.person.PersonContactDetailDto; +import de.symeda.sormas.api.person.PersonContactDetailType; +import de.symeda.sormas.api.person.PersonDto; +import de.symeda.sormas.api.person.PhoneNumberType; +import de.symeda.sormas.backend.AbstractUnitTest; + +class PersonContactDetailsFieldMapperTest extends AbstractUnitTest { + + @InjectMocks + private PersonContactDetailsFieldMapper victim; + + @Test + void supportedFields_containsPhoneNumberTypeAndDetails() { + // PREPARE + Set expected = Set.of("Person.personContactDetails.contactInformation", "Person.personContactDetails.phoneNumberType"); + + // EXECUTE + Set actual = victim.supportedFields(); + + // CHECK + assertEquals(expected, actual); + } + + // map - wrong target type + + @Test + void map_targetNotPersonDto_returnsTechnicalFailure() { + // PREPARE + FieldPatchRequest request = mock(FieldPatchRequest.class); + when(request.getTarget()).thenReturn(new Object()); + + // EXECUTE + Optional actual = victim.map(request); + + // CHECK + assertTrue(actual.isPresent()); + assertEquals(DataPatchFailureCause.TECHNICAL, actual.get().getDataPatchFailureCause()); + } + + @Test + void map_phoneField_contactDetailNotPresent_addsPhoneContactDetail() { + // PREPARE + PersonDto personDto = new PersonDto(); + personDto.setPersonContactDetails(new ArrayList<>()); + + FieldPatchRequest request = mock(FieldPatchRequest.class); + when(request.getTarget()).thenReturn(personDto); + when(request.getFieldName()).thenReturn("Person.PersonContactDetail.phoneNumberType"); + when(request.getValue()).thenReturn("0123456789"); + when(request.getOrigin()).thenReturn("someOrigin"); + + // EXECUTE + Optional actual = victim.map(request); + + // CHECK + assertTrue(actual.isEmpty()); + assertEquals(1, personDto.getPersonContactDetails().size()); + PersonContactDetailDto added = personDto.getPersonContactDetails().get(0); + assertEquals(PersonContactDetailType.PHONE, added.getPersonContactDetailType()); + assertEquals("0123456789", added.getContactInformation()); + assertEquals(PhoneNumberType.OTHER, added.getPhoneNumberType()); + assertEquals("someOrigin", added.getAdditionalInformation()); + } + + @Test + void map_phoneField_contactDetailAlreadyPresent_doesNotAddDuplicate() { + // PREPARE + PersonContactDetailDto existing = new PersonContactDetailDto(); + existing.setPersonContactDetailType(PersonContactDetailType.PHONE); + existing.setContactInformation("0123456789"); + + PersonDto personDto = new PersonDto(); + personDto.setPersonContactDetails(new ArrayList<>(List.of(existing))); + + FieldPatchRequest request = mock(FieldPatchRequest.class); + when(request.getTarget()).thenReturn(personDto); + when(request.getFieldName()).thenReturn("Person.PersonContactDetail.phoneNumberType"); + when(request.getValue()).thenReturn("0123456789"); + + // EXECUTE + Optional actual = victim.map(request); + + // CHECK + assertTrue(actual.isEmpty()); + assertEquals(1, personDto.getPersonContactDetails().size()); + } + + @Test + void map_phoneField_differentValueAlreadyPresent_addsNewEntry() { + // PREPARE + PersonContactDetailDto existing = new PersonContactDetailDto(); + existing.setPersonContactDetailType(PersonContactDetailType.PHONE); + existing.setDetails("0000000000"); + + PersonDto personDto = new PersonDto(); + personDto.setPersonContactDetails(new ArrayList<>(List.of(existing))); + + FieldPatchRequest request = mock(FieldPatchRequest.class); + when(request.getTarget()).thenReturn(personDto); + when(request.getFieldName()).thenReturn("Person.PersonContactDetail.phoneNumberType"); + when(request.getValue()).thenReturn("0123456789"); + when(request.getOrigin()).thenReturn("someOrigin"); + + // EXECUTE + Optional actual = victim.map(request); + + // CHECK + assertTrue(actual.isEmpty()); + assertEquals(2, personDto.getPersonContactDetails().size()); + } + + @Test + void map_emailField_contactDetailNotPresent_addsEmailContactDetail() { + // PREPARE + PersonDto personDto = new PersonDto(); + personDto.setPersonContactDetails(new ArrayList<>()); + + FieldPatchRequest request = mock(FieldPatchRequest.class); + when(request.getTarget()).thenReturn(personDto); + when(request.getFieldName()).thenReturn("Person.PersonContactDetail.contactInformation"); + when(request.getValue()).thenReturn("test@example.com"); + when(request.getOrigin()).thenReturn("someOrigin"); + + // EXECUTE + Optional actual = victim.map(request); + + // CHECK + assertTrue(actual.isEmpty()); + assertEquals(1, personDto.getPersonContactDetails().size()); + PersonContactDetailDto added = personDto.getPersonContactDetails().get(0); + assertEquals(PersonContactDetailType.EMAIL, added.getPersonContactDetailType()); + assertEquals("test@example.com", added.getContactInformation()); + assertEquals("someOrigin", added.getAdditionalInformation()); + } + + @Test + void map_emailField_contactDetailAlreadyPresent_doesNotAddDuplicate() { + // PREPARE + PersonContactDetailDto existing = new PersonContactDetailDto(); + existing.setPersonContactDetailType(PersonContactDetailType.EMAIL); + existing.setContactInformation("test@example.com"); + + PersonDto personDto = new PersonDto(); + personDto.setPersonContactDetails(new ArrayList<>(List.of(existing))); + + FieldPatchRequest request = mock(FieldPatchRequest.class); + when(request.getTarget()).thenReturn(personDto); + when(request.getFieldName()).thenReturn("Person.PersonContactDetail.contactInformation"); + when(request.getValue()).thenReturn("test@example.com"); + + // EXECUTE + Optional actual = victim.map(request); + + // CHECK + assertTrue(actual.isEmpty()); + assertEquals(1, personDto.getPersonContactDetails().size()); + } + + @Test + void map_emailField_contactInformationAlreadyPresent_doesNotAddDuplicate() { + // PREPARE + PersonContactDetailDto existing = new PersonContactDetailDto(); + existing.setPersonContactDetailType(PersonContactDetailType.EMAIL); + existing.setContactInformation("test@example.com"); + + PersonDto personDto = new PersonDto(); + personDto.setPersonContactDetails(new ArrayList<>(List.of(existing))); + + FieldPatchRequest request = mock(FieldPatchRequest.class); + when(request.getTarget()).thenReturn(personDto); + when(request.getFieldName()).thenReturn("PersonContactDetail.contactInformation"); + when(request.getValue()).thenReturn("test@example.com"); + + // EXECUTE + Optional actual = victim.map(request); + + // CHECK + assertTrue(actual.isEmpty()); + assertEquals(1, personDto.getPersonContactDetails().size()); + } + + @Test + void map_emailField_differentValueAlreadyPresent_addsNewEntry() { + // PREPARE + PersonContactDetailDto existing = new PersonContactDetailDto(); + existing.setPersonContactDetailType(PersonContactDetailType.EMAIL); + existing.setDetails("other@example.com"); + + PersonDto personDto = new PersonDto(); + personDto.setPersonContactDetails(new ArrayList<>(List.of(existing))); + + FieldPatchRequest request = mock(FieldPatchRequest.class); + when(request.getTarget()).thenReturn(personDto); + when(request.getFieldName()).thenReturn("Person.PersonContactDetail.contactInformation"); + when(request.getValue()).thenReturn("test@example.com"); + when(request.getOrigin()).thenReturn("someOrigin"); + + // EXECUTE + Optional actual = victim.map(request); + + // CHECK + assertTrue(actual.isEmpty()); + assertEquals(2, personDto.getPersonContactDetails().size()); + } + + @Test + void map_phoneField_existingEmailWithSameValue_addsPhoneContactDetail() { + // PREPARE + PersonContactDetailDto existingEmail = new PersonContactDetailDto(); + existingEmail.setPersonContactDetailType(PersonContactDetailType.EMAIL); + existingEmail.setDetails("0123456789"); + + PersonDto personDto = new PersonDto(); + personDto.setPersonContactDetails(new ArrayList<>(List.of(existingEmail))); + + FieldPatchRequest request = mock(FieldPatchRequest.class); + when(request.getTarget()).thenReturn(personDto); + when(request.getFieldName()).thenReturn("Person.PersonContactDetail.phoneNumberType"); + when(request.getValue()).thenReturn("0123456789"); + when(request.getOrigin()).thenReturn("someOrigin"); + + // EXECUTE + Optional actual = victim.map(request); + + // CHECK + assertTrue(actual.isEmpty()); + assertEquals(2, personDto.getPersonContactDetails().size()); + } + + @Test + void map_emailField_existingPhoneWithSameValue_addsEmailContactDetail() { + // PREPARE + PersonContactDetailDto existingPhone = new PersonContactDetailDto(); + existingPhone.setPersonContactDetailType(PersonContactDetailType.PHONE); + existingPhone.setDetails("test@example.com"); + + PersonDto personDto = new PersonDto(); + personDto.setPersonContactDetails(new ArrayList<>(List.of(existingPhone))); + + FieldPatchRequest request = mock(FieldPatchRequest.class); + when(request.getTarget()).thenReturn(personDto); + when(request.getFieldName()).thenReturn("Person.PersonContactDetail.contactInformation"); + when(request.getValue()).thenReturn("test@example.com"); + when(request.getOrigin()).thenReturn("someOrigin"); + + // EXECUTE + Optional actual = victim.map(request); + + // CHECK + assertTrue(actual.isEmpty()); + assertEquals(2, personDto.getPersonContactDetails().size()); + } +} diff --git a/sormas-backend/src/test/java/de/symeda/sormas/backend/patch/mapping/impl/valuemapper/CustomizableEnumPatchMapperTest.java b/sormas-backend/src/test/java/de/symeda/sormas/backend/patch/mapping/impl/valuemapper/CustomizableEnumPatchMapperTest.java new file mode 100644 index 00000000000..433e987cd78 --- /dev/null +++ b/sormas-backend/src/test/java/de/symeda/sormas/backend/patch/mapping/impl/valuemapper/CustomizableEnumPatchMapperTest.java @@ -0,0 +1,235 @@ +package de.symeda.sormas.backend.patch.mapping.impl.valuemapper; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import de.symeda.sormas.backend.customizableenum.CustomizableEnumFacadeEjb; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; + +import de.symeda.sormas.api.Language; +import de.symeda.sormas.api.customizableenum.CustomizableEnum; +import de.symeda.sormas.api.customizableenum.CustomizableEnumType; +import de.symeda.sormas.api.i18n.I18nProperties; +import de.symeda.sormas.api.i18n.I18nPropertiesRequest; +import de.symeda.sormas.api.patch.DataPatchFailureCause; +import de.symeda.sormas.api.customizableenum.CustomizableEnumFacade; +import de.symeda.sormas.api.patch.mapping.ValuePatchRequest; +import de.symeda.sormas.api.person.OccupationType; +import de.symeda.sormas.backend.AbstractUnitTest; + +class CustomizableEnumPatchMapperTest extends AbstractUnitTest { + + @Mock + private CustomizableEnumFacadeEjb.CustomizableEnumFacadeEjbLocal customizableEnumFacade; + + @InjectMocks + private CustomizableEnumPatchMapper victim; + + @Test + void getSupportedTypes_containsCustomizableEnumClass() { + assertEquals(Set.of(CustomizableEnum.class), victim.getSupportedTypes()); + } + + @Test + void map_nonCustomizableEnumTargetType_throwsIllegalArgumentException() { + assertThrows(IllegalArgumentException.class, () -> victim.map("HEALTHCARE_WORKER", String.class)); + } + + @Test + void map_unregisteredCustomizableEnumType_throwsIllegalArgumentException() { + assertThrows( + IllegalArgumentException.class, + () -> victim.map(new ValuePatchRequest().setValue("VALUE").setTargetType(UnregisteredEnum.class))); + } + + @Test + void map_matchByValue_returnsEnumValue() { + // PREPARE + OccupationType expected = occupationType("HEALTHCARE_WORKER", "Healthcare Worker"); + when(customizableEnumFacade.getEnumValues(CustomizableEnumType.OCCUPATION_TYPE, null)).thenReturn(List.of(expected)); + + // EXECUTE + OccupationType actual = victim.map("HEALTHCARE_WORKER", OccupationType.class).getData(); + + // CHECK + assertEquals(expected, actual); + } + + @Test + void map_matchByCaption_returnsEnumValue() { + // PREPARE + OccupationType expected = occupationType("HEALTHCARE_WORKER", "Healthcare Worker"); + when(customizableEnumFacade.getEnumValues(CustomizableEnumType.OCCUPATION_TYPE, null)).thenReturn(List.of(expected)); + + // EXECUTE + OccupationType actual = victim.map("Healthcare Worker", OccupationType.class).getData(); + + // CHECK + assertEquals(expected, actual); + } + + @Test + void map_matchByValueCaseInsensitive_returnsEnumValue() { + // PREPARE + OccupationType expected = occupationType("HEALTHCARE_WORKER", "Healthcare Worker"); + when(customizableEnumFacade.getEnumValues(CustomizableEnumType.OCCUPATION_TYPE, null)).thenReturn(List.of(expected)); + + // EXECUTE + OccupationType actual = victim.map("healthcare_worker", OccupationType.class).getData(); + + // CHECK + assertEquals(expected, actual); + } + + @Test + void map_matchByCaptionWithAccents_returnsEnumValue() { + // PREPARE - stored caption has no accent, input does + OccupationType expected = occupationType("INFIRMIER", "Infirmier"); + when(customizableEnumFacade.getEnumValues(CustomizableEnumType.OCCUPATION_TYPE, null)).thenReturn(List.of(expected)); + + // EXECUTE + OccupationType actual = victim.map("Infïrmier", OccupationType.class).getData(); + + // CHECK + assertEquals(expected, actual); + } + + @Test + void map_notFoundInDefaultLanguage_foundByInputLanguage_returnsEnumValue() { + // PREPARE + OccupationType expected = occupationType("HEALTHCARE_WORKER", "Healthcare Worker"); + when(customizableEnumFacade.getEnumValues(CustomizableEnumType.OCCUPATION_TYPE, null)).thenReturn(List.of()); + when(customizableEnumFacade.getEnumValue(CustomizableEnumType.OCCUPATION_TYPE, null, "HEALTHCARE_WORKER")).thenReturn(expected); + + try (MockedStatic mockedI18n = mockStatic(I18nProperties.class)) { + mockedI18n.when(() -> I18nProperties.buildKeyValueDictionary(any(I18nPropertiesRequest.class))) + .thenReturn(Map.of("HEALTHCARE_WORKER", "Agent de sante")); + + ValuePatchRequest request = new ValuePatchRequest().setValue("Agent de sante") + .setTargetType(OccupationType.class) + .setInputLanguages(List.of(Language.FR)); + + // EXECUTE + OccupationType actual = victim.map(request).getData(); + + // CHECK + assertEquals(expected, actual); + } + } + + @Test + void map_notFoundInDefaultLanguage_noInputLanguages_usesUserLanguage() { + // PREPARE + OccupationType expected = occupationType("HEALTHCARE_WORKER", "Healthcare Worker"); + when(customizableEnumFacade.getEnumValues(CustomizableEnumType.OCCUPATION_TYPE, null)).thenReturn(List.of()); + when(customizableEnumFacade.getEnumValue(CustomizableEnumType.OCCUPATION_TYPE, null, "HEALTHCARE_WORKER")).thenReturn(expected); + + try (MockedStatic mockedI18n = mockStatic(I18nProperties.class)) { + mockedI18n.when(I18nProperties::getUserLanguage).thenReturn(Language.DE); + mockedI18n.when(() -> I18nProperties.buildKeyValueDictionary(any(I18nPropertiesRequest.class))) + .thenReturn(Map.of("HEALTHCARE_WORKER", "Gesundheitsarbeiter")); + + ValuePatchRequest request = + new ValuePatchRequest().setValue("Gesundheitsarbeiter").setTargetType(OccupationType.class); + + // EXECUTE + OccupationType actual = victim.map(request).getData(); + + // CHECK + assertEquals(expected, actual); + } + } + + @Test + void map_noMatch_fallbackAllowed_otherPresent_returnsOtherEnumValue() { + // PREPARE + OccupationType otherEnum = occupationType(CustomizableEnumPatchMapper.FALLBACK_NAME, "Other"); + when(customizableEnumFacade.getEnumValues(CustomizableEnumType.OCCUPATION_TYPE, null)).thenReturn(List.of()); + when(customizableEnumFacade.getEnumValue(CustomizableEnumType.OCCUPATION_TYPE, null, CustomizableEnumPatchMapper.FALLBACK_NAME)) + .thenReturn(otherEnum); + + try (MockedStatic mockedI18n = mockStatic(I18nProperties.class)) { + mockedI18n.when(I18nProperties::getUserLanguage).thenReturn(Language.EN); + mockedI18n.when(() -> I18nProperties.buildKeyValueDictionary(any(I18nPropertiesRequest.class))).thenReturn(Map.of()); + + ValuePatchRequest request = + new ValuePatchRequest().setValue("UNKNOWN_VALUE").setTargetType(OccupationType.class).setAllowFallbackValues(true); + + // EXECUTE + OccupationType actual = victim.map(request).getData(); + + // CHECK + assertEquals(otherEnum, actual); + } + } + + @Test + void map_noMatch_fallbackAllowed_otherNotPresent_returnsFailureCause() { + // PREPARE + when(customizableEnumFacade.getEnumValues(CustomizableEnumType.OCCUPATION_TYPE, null)).thenReturn(List.of()); + when(customizableEnumFacade.getEnumValue(CustomizableEnumType.OCCUPATION_TYPE, null, CustomizableEnumPatchMapper.FALLBACK_NAME)) + .thenReturn(null); + + try (MockedStatic mockedI18n = mockStatic(I18nProperties.class)) { + mockedI18n.when(I18nProperties::getUserLanguage).thenReturn(Language.EN); + mockedI18n.when(() -> I18nProperties.buildKeyValueDictionary(any(I18nPropertiesRequest.class))).thenReturn(Map.of()); + + ValuePatchRequest request = + new ValuePatchRequest().setValue("UNKNOWN_VALUE").setTargetType(OccupationType.class).setAllowFallbackValues(true); + + // EXECUTE & CHECK + assertEquals(DataPatchFailureCause.NOT_PRESENT_IN_REFERENCE_DATA_LIST, victim.map(request).getDataPatchFailureCause()); + } + } + + @Test + void map_noMatch_fallbackDisabled_returnsFailureCause() { + // PREPARE + when(customizableEnumFacade.getEnumValues(CustomizableEnumType.OCCUPATION_TYPE, null)).thenReturn(List.of()); + + try (MockedStatic mockedI18n = mockStatic(I18nProperties.class)) { + mockedI18n.when(I18nProperties::getUserLanguage).thenReturn(Language.EN); + mockedI18n.when(() -> I18nProperties.buildKeyValueDictionary(any(I18nPropertiesRequest.class))).thenReturn(Map.of()); + + ValuePatchRequest request = + new ValuePatchRequest().setValue("UNKNOWN_VALUE").setTargetType(OccupationType.class).setAllowFallbackValues(false); + + // EXECUTE & CHECK + assertEquals(DataPatchFailureCause.NOT_PRESENT_IN_REFERENCE_DATA_LIST, victim.map(request).getDataPatchFailureCause()); + } + } + + private static OccupationType occupationType(String value, String caption) { + OccupationType occupationType = new OccupationType(); + occupationType.setValue(value); + occupationType.setCaption(caption); + return occupationType; + } + + private static class UnregisteredEnum extends CustomizableEnum { + + @Override + public void setProperties(Map properties) { + } + + @Override + public boolean matchPropertyValue(String property, Object value) { + return false; + } + + @Override + public Map> getAllProperties() { + return Map.of(); + } + } +} diff --git a/sormas-backend/src/test/java/de/symeda/sormas/backend/patch/mapping/impl/valuemapper/DateMapperTest.java b/sormas-backend/src/test/java/de/symeda/sormas/backend/patch/mapping/impl/valuemapper/DateMapperTest.java new file mode 100644 index 00000000000..5a231232335 --- /dev/null +++ b/sormas-backend/src/test/java/de/symeda/sormas/backend/patch/mapping/impl/valuemapper/DateMapperTest.java @@ -0,0 +1,112 @@ +package de.symeda.sormas.backend.patch.mapping.impl.valuemapper; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; + +import de.symeda.sormas.api.patch.DataPatchFailureCause; +import de.symeda.sormas.backend.AbstractUnitTest; + +class DateMapperTest extends AbstractUnitTest { + + @InjectMocks + private DatePatchMapper victim; + + @Test + void getSupportedTypes_containsDateClass() { + // PREPARE + Set> expected = Set.of(Date.class); + + // EXECUTE + Set> actual = victim.getSupportedTypes(); + + // CHECK + assertEquals(expected, actual); + } + + @Test + void map_validDate() throws Exception { + // PREPARE + String input = "2024-06-15"; + Date expected = new SimpleDateFormat("yyyy-MM-dd").parse(input); + + // EXECUTE + Date actual = victim.map(input, Date.class).getData(); + + // CHECK + assertEquals(expected, actual); + } + + @Test + void map_firstDayOfYear() throws Exception { + // PREPARE + String input = "2024-01-01"; + Date expected = new SimpleDateFormat("yyyy-MM-dd").parse(input); + + // EXECUTE + Date actual = victim.map(input, Date.class).getData(); + + // CHECK + assertEquals(expected, actual); + } + + @Test + void map_lastDayOfYear() throws Exception { + // PREPARE + String input = "2024-12-31"; + Date expected = new SimpleDateFormat("yyyy-MM-dd").parse(input); + + // EXECUTE + Date actual = victim.map(input, Date.class).getData(); + + // CHECK + assertEquals(expected, actual); + } + + @Test + void map_valueAsNonStringObject_usesToString() throws Exception { + // PREPARE + Date expected = new SimpleDateFormat("yyyy-MM-dd").parse("2024-06-15"); + + // EXECUTE + Date actual = victim.map(new StringBuilder("2024-06-15"), Date.class).getData(); + + // CHECK + assertEquals(expected, actual); + } + + @Test + void map_invalidFormat_throwsIllegalArgumentException() { + // EXECUTE & CHECK + assertEquals(DataPatchFailureCause.INVALID_VALUE_TYPE, victim.map("15/06/2024", Date.class).getDataPatchFailureCause()); + } + + @Test + void map_lenientOff_invalidDay_throwsIllegalArgumentException() { + // EXECUTE & CHECK + assertEquals(DataPatchFailureCause.INVALID_VALUE_TYPE, victim.map("2024-02-30", Date.class).getDataPatchFailureCause()); + } + + @Test + void map_lenientOff_invalidMonth_throwsIllegalArgumentException() { + // EXECUTE & CHECK + assertEquals(DataPatchFailureCause.INVALID_VALUE_TYPE, victim.map("2024-13-01", Date.class).getDataPatchFailureCause()); + } + + @Test + void map_emptyString_throwsIllegalArgumentException() { + // EXECUTE & CHECK + assertEquals(DataPatchFailureCause.INVALID_VALUE_TYPE, victim.map("", Date.class).getDataPatchFailureCause()); + } + + @Test + void map_randomString_throwsIllegalArgumentException() { + // EXECUTE & CHECK + assertEquals(DataPatchFailureCause.INVALID_VALUE_TYPE, victim.map("notADate", Date.class).getDataPatchFailureCause()); + } +} diff --git a/sormas-backend/src/test/java/de/symeda/sormas/backend/patch/mapping/impl/valuemapper/EnumMapperTest.java b/sormas-backend/src/test/java/de/symeda/sormas/backend/patch/mapping/impl/valuemapper/EnumMapperTest.java new file mode 100644 index 00000000000..e51a0183a25 --- /dev/null +++ b/sormas-backend/src/test/java/de/symeda/sormas/backend/patch/mapping/impl/valuemapper/EnumMapperTest.java @@ -0,0 +1,140 @@ +package de.symeda.sormas.backend.patch.mapping.impl.valuemapper; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; + +import de.symeda.sormas.api.caze.InfectionSetting; +import de.symeda.sormas.api.caze.Trimester; +import de.symeda.sormas.api.patch.DataPatchFailureCause; +import de.symeda.sormas.api.person.Sex; +import de.symeda.sormas.backend.AbstractUnitTest; + +class EnumMapperTest extends AbstractUnitTest { + + @InjectMocks + private EnumPatchMapper victim; + + @Test + void getSupportedTypes_containsEnumClass() { + // PREPARE + Set> expected = Set.of(Enum.class); + + // EXECUTE + Set> actual = victim.getSupportedTypes(); + + // CHECK + assertEquals(expected, actual); + } + + @Test + void map_sex_exactMatch_male() { + // EXECUTE & CHECK + assertEquals(Sex.MALE, victim.map("MALE", Sex.class).getData()); + } + + @Test + void map_sex_exactMatch_female() { + // EXECUTE & CHECK + assertEquals(Sex.FEMALE, victim.map("FEMALE", Sex.class).getData()); + } + + @Test + void map_sex_exactMatch_unknown() { + // EXECUTE & CHECK + assertEquals(Sex.UNKNOWN, victim.map("UNKNOWN", Sex.class).getData()); + } + + @Test + void map_sex_caseInsensitive_lowercase() { + // EXECUTE & CHECK + assertEquals(Sex.MALE, victim.map("male", Sex.class).getData()); + } + + @Test + void map_sex_caseInsensitive_mixedCase() { + // EXECUTE & CHECK + assertEquals(Sex.FEMALE, victim.map("fEmAlE", Sex.class).getData()); + } + + @Test + void map_sex_trimsWhitespace() { + // EXECUTE & CHECK + assertEquals(Sex.MALE, victim.map(" MALE ", Sex.class).getData()); + } + + @Test + void map_sex_unknownValue_fallsBackToOther() { + // EXECUTE & CHECK + assertEquals(Sex.OTHER, victim.map("SOMETHING_UNKNOWN", Sex.class).getData()); + } + + @Test + void map_infectionSetting_exactMatch_ambulatory() { + // EXECUTE & CHECK + assertEquals(InfectionSetting.AMBULATORY, victim.map("AMBULATORY", InfectionSetting.class).getData()); + } + + @Test + void map_infectionSetting_exactMatch_normalWard() { + // EXECUTE & CHECK + assertEquals(InfectionSetting.NORMAL_WARD, victim.map("NORMAL_WARD", InfectionSetting.class).getData()); + } + + @Test + void map_infectionSetting_unknownValue_fallsBackToAnnotatedDefault() { + // EXECUTE & CHECK + assertEquals(InfectionSetting.UNKNOWN, victim.map("SOMETHING_UNKNOWN", InfectionSetting.class).getData()); + } + + @Test + void map_trimester_exactMatch_first() { + // EXECUTE & CHECK + assertEquals(Trimester.FIRST, victim.map("FIRST", Trimester.class).getData()); + } + + @Test + void map_trimester_exactMatch_second() { + // EXECUTE & CHECK + assertEquals(Trimester.SECOND, victim.map("SECOND", Trimester.class).getData()); + } + + @Test + void map_trimester_exactMatch_third() { + // EXECUTE & CHECK + assertEquals(Trimester.THIRD, victim.map("THIRD", Trimester.class).getData()); + } + + @Test + void map_trimester_unknownValue_fallsBackToAnnotatedDefault() { + // EXECUTE & CHECK + assertEquals(Trimester.UNKNOWN, victim.map("SOMETHING_UNKNOWN", Trimester.class).getData()); + } + + @Test + void map_noFallback_throwsEnumConstantNotPresentException() { + // PREPARE + // Direction has no OTHER constant and no @ValueMapperDefault annotation + + // EXECUTE & CHECK + assertEquals( + DataPatchFailureCause.NOT_PRESENT_IN_REFERENCE_DATA_LIST, + victim.map("SOMETHING_UNKNOWN", NoFallbackEnum.class).getDataPatchFailureCause()); + } + + @Test + void map_noFallback_notAnEnum() { + // EXECUTE & CHECK + assertEquals(DataPatchFailureCause.TECHNICAL, victim.map("SOMETHING_UNKNOWN", Long.class).getDataPatchFailureCause()); + } + + private enum NoFallbackEnum { + NORTH, + SOUTH, + EAST, + WEST + } +} diff --git a/sormas-backend/src/test/java/de/symeda/sormas/backend/patch/mapping/impl/valuemapper/PrimitiveMapperTest.java b/sormas-backend/src/test/java/de/symeda/sormas/backend/patch/mapping/impl/valuemapper/PrimitiveMapperTest.java new file mode 100644 index 00000000000..f3679d843b6 --- /dev/null +++ b/sormas-backend/src/test/java/de/symeda/sormas/backend/patch/mapping/impl/valuemapper/PrimitiveMapperTest.java @@ -0,0 +1,216 @@ +package de.symeda.sormas.backend.patch.mapping.impl.valuemapper; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.InjectMocks; + +import de.symeda.sormas.api.Language; +import de.symeda.sormas.api.patch.DataPatchFailureCause; +import de.symeda.sormas.api.patch.mapping.ValueMappingResult; +import de.symeda.sormas.api.patch.mapping.ValuePatchRequest; +import de.symeda.sormas.backend.AbstractUnitTest; + +class PrimitiveMapperTest extends AbstractUnitTest { + + @InjectMocks + private PrimitivePatchMapper victim; + + @Test + void map_string() { + // PREPARE + String expected = "toto"; + // EXECUTE & CHECK + assertEquals(expected, victim.map(expected, String.class).getData()); + } + + @Test + void map_Integer() { + // PREPARE + String input = "50"; + + // EXECUTE + Integer actual = victim.map(input, Integer.class).getData(); + + // CHECK + assertEquals(50, actual); + } + + @Test + void getSupportedTypes_containsAllExpectedTypes() { + // PREPARE + Set> expected = Set.of( + String.class, + int.class, + Integer.class, + long.class, + Long.class, + BigDecimal.class, + double.class, + Double.class, + float.class, + Float.class, + Boolean.class, + boolean.class); + + // EXECUTE + Set> actual = victim.getSupportedTypes(); + + // CHECK + assertEquals(expected, actual); + } + + // map - happy paths + + @Test + void map_string_trimsWhitespace() { + // EXECUTE & CHECK + assertEquals("hello", victim.map(" hello ", String.class).getData()); + } + + @Test + void map_integer() { + // PREPARE + String input = "42"; + + // EXECUTE + Integer actual = victim.map(input, Integer.class).getData(); + + // CHECK + assertEquals(42, actual); + } + + @Test + void map_double() { + // PREPARE + String input = "3.14"; + + // EXECUTE + Double actual = victim.map(input, Double.class).getData(); + + // CHECK + assertEquals(3.14, actual); + } + + @Test + void map_float() { + // PREPARE + String input = "1.5"; + + // EXECUTE + Float actual = victim.map(input, Float.class).getData(); + + // CHECK + assertEquals(1.5f, actual); + } + + @Test + void map_boolean_true() { + // PREPARE + String input = "true"; + + // EXECUTE + Boolean actual = victim.map(input, Boolean.class).getData(); + + // CHECK + assertTrue(actual); + } + + @Test + void map_boolean_false() { + // PREPARE + String input = "false"; + + // EXECUTE + Boolean actual = victim.map(input, Boolean.class).getData(); + + // CHECK + assertFalse(actual); + } + + @Test + void map_primitiveBooleanClass_true() { + // PREPARE + String input = "true"; + + // EXECUTE + Boolean actual = victim.map(input, boolean.class).getData(); + + // CHECK + assertTrue(actual); + } + + // map - edge cases + + @Test + void map_integer_withSurroundingWhitespace() { + // EXECUTE & CHECK + assertEquals(99, victim.map(" 99 ", Integer.class).getData()); + } + + @Test + void map_boolean_invalidString_returnsFalse() { + // EXECUTE & CHECK + assertFalse(victim.map("notABoolean", Boolean.class).getData()); + } + + @ParameterizedTest + @ValueSource(strings = { + " yes ", + " JA", + "oUi " }) + void map_boolean_translation_true(String trueString) { + // EXECUTE & CHECK + assertTrue( + victim + .map( + new ValuePatchRequest().setInputLanguages(List.of(Language.DE, Language.FR, Language.EN)) + .setTargetType(Boolean.class) + .setValue(trueString)) + .getData()); + } + + @Test + void map_boolean_translation_true_but_other_language() { + // EXECUTE & CHECK + assertFalse( + victim.map(new ValuePatchRequest().setInputLanguages(List.of(Language.DE)).setTargetType(Boolean.class).setValue("OUI")) + .getData()); + } + + @Test + void map_unsupportedType_throwsIllegalArgumentException() { + // PREPARE + String input = "value"; + + // EXECUTE & CHECK + assertEquals(ValueMappingResult.withCause(DataPatchFailureCause.INVALID_VALUE_TYPE), victim.map(input, Long.class)); + } + + @Test + void map_invalidIntegerFormat_throwsNumberFormatException() { + // EXECUTE & CHECK + assertEquals(ValueMappingResult.withCause(DataPatchFailureCause.INVALID_VALUE_TYPE), victim.map("notAnInt", Integer.class)); + } + + @Test + void map_invalidDoubleFormat_throwsNumberFormatException() { + // EXECUTE & CHECK + assertEquals(ValueMappingResult.withCause(DataPatchFailureCause.INVALID_VALUE_TYPE), victim.map("notADouble", Double.class)); + + } + + @Test + void map_invalidFloatFormat_throwsNumberFormatException() { + // EXECUTE & CHECK + assertEquals(ValueMappingResult.withCause(DataPatchFailureCause.INVALID_VALUE_TYPE), victim.map("notAFloat", Float.class)); + } +} diff --git a/sormas-backend/src/test/java/de/symeda/sormas/backend/patch/partial_retrieval/ContactDetailsFieldValueRetrieverTest.java b/sormas-backend/src/test/java/de/symeda/sormas/backend/patch/partial_retrieval/ContactDetailsFieldValueRetrieverTest.java new file mode 100644 index 00000000000..d918c271fea --- /dev/null +++ b/sormas-backend/src/test/java/de/symeda/sormas/backend/patch/partial_retrieval/ContactDetailsFieldValueRetrieverTest.java @@ -0,0 +1,159 @@ +package de.symeda.sormas.backend.patch.partial_retrieval; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; + +import de.symeda.sormas.api.patch.partial_retrieval.FieldInfo; +import de.symeda.sormas.api.person.PersonContactDetailDto; +import de.symeda.sormas.api.person.PersonContactDetailType; +import de.symeda.sormas.api.person.PersonDto; +import de.symeda.sormas.backend.AbstractUnitTest; + +class ContactDetailsFieldValueRetrieverTest extends AbstractUnitTest { + + @InjectMocks + private ContactDetailsFieldValueRetriever victim; + + private static final String PHONE_FIELD = PersonContactDetailDto.I18N_PREFIX + "." + PersonContactDetailDto.PHONE_NUMBER_TYPE; + private static final String EMAIL_FIELD = PersonContactDetailDto.I18N_PREFIX + "." + PersonContactDetailDto.CONTACT_INFORMATION; + + @Test + void getSupportedFields_returnsPhoneAndEmailFields() { + assertEquals(Set.of(PHONE_FIELD, EMAIL_FIELD), victim.getSupportedFields()); + } + + @Test + void supports_phoneField_returnsTrue() { + assertTrue(victim.supports(PHONE_FIELD)); + } + + @Test + void supports_emailField_returnsTrue() { + assertTrue(victim.supports(EMAIL_FIELD)); + } + + @Test + void supports_unknownField_returnsFalse() { + assertFalse(victim.supports("Person.firstName")); + } + + @Test + void getFieldInfo_phone_noContacts_returnsEmptyValue() { + FieldInfo result = victim.getFieldInfo(PHONE_FIELD, new PersonDto()); + + assertEquals("", result.getFieldValue()); + } + + @Test + void getFieldInfo_phone_fieldTypeIsList() { + FieldInfo result = victim.getFieldInfo(PHONE_FIELD, new PersonDto()); + + assertEquals(List.class, result.getFieldType()); + } + + @Test + void getFieldInfo_phone_translatedFieldName() { + FieldInfo result = victim.getFieldInfo(PHONE_FIELD, new PersonDto()); + + assertEquals("Phone number type", result.getTranslatedFieldName()); + } + + @Test + void getFieldInfo_phone_singlePhone_returnsNumber() { + PersonDto person = personWithContacts(phoneContact("0987654321")); + + FieldInfo result = victim.getFieldInfo(PHONE_FIELD, person); + + assertEquals("0987654321", result.getFieldValue()); + } + + @Test + void getFieldInfo_phone_multiplePhones_returnsSortedAndJoined() { + PersonDto person = personWithContacts(phoneContact("9999999"), phoneContact("1111111"), phoneContact("5555555")); + + FieldInfo result = victim.getFieldInfo(PHONE_FIELD, person); + + assertEquals("1111111; 5555555; 9999999", result.getFieldValue()); + } + + @Test + void getFieldInfo_phone_blankContactInfoFiltered() { + PersonDto person = personWithContacts(phoneContact("0987654321"), phoneContact(" "), phoneContact(null)); + + FieldInfo result = victim.getFieldInfo(PHONE_FIELD, person); + + assertEquals("0987654321", result.getFieldValue()); + } + + @Test + void getFieldInfo_phone_emailContactsExcluded() { + PersonDto person = personWithContacts(phoneContact("0987654321"), emailContact("test@example.com")); + + FieldInfo result = victim.getFieldInfo(PHONE_FIELD, person); + + assertEquals("0987654321", result.getFieldValue()); + } + + @Test + void getFieldInfo_email_singleEmail_returnsEmail() { + PersonDto person = personWithContacts(emailContact("user@example.com")); + + FieldInfo result = victim.getFieldInfo(EMAIL_FIELD, person); + + assertEquals("user@example.com", result.getFieldValue()); + } + + @Test + void getFieldInfo_email_multipleEmails_returnsSortedAndJoined() { + PersonDto person = personWithContacts(emailContact("z@example.com"), emailContact("a@example.com")); + + FieldInfo result = victim.getFieldInfo(EMAIL_FIELD, person); + + assertEquals("a@example.com; z@example.com", result.getFieldValue()); + } + + @Test + void getFieldInfo_email_phoneContactsExcluded() { + PersonDto person = personWithContacts(emailContact("user@example.com"), phoneContact("0987654321")); + + FieldInfo result = victim.getFieldInfo(EMAIL_FIELD, person); + + assertEquals("user@example.com", result.getFieldValue()); + } + + @Test + void getFieldInfo_email_translatedFieldName() { + FieldInfo result = victim.getFieldInfo(EMAIL_FIELD, new PersonDto()); + + assertEquals("Contact information", result.getTranslatedFieldName()); + } + + private PersonDto personWithContacts(PersonContactDetailDto... details) { + PersonDto person = new PersonDto(); + for (PersonContactDetailDto detail : details) { + person.getPersonContactDetails().add(detail); + } + return person; + } + + private PersonContactDetailDto phoneContact(String number) { + PersonContactDetailDto dto = new PersonContactDetailDto(); + dto.setPersonContactDetailType(PersonContactDetailType.PHONE); + dto.setContactInformation(number); + return dto; + } + + private PersonContactDetailDto emailContact(String email) { + PersonContactDetailDto dto = new PersonContactDetailDto(); + dto.setPersonContactDetailType(PersonContactDetailType.EMAIL); + dto.setContactInformation(email); + return dto; + } +} diff --git a/sormas-backend/src/test/java/de/symeda/sormas/backend/patch/partial_retrieval/PartialRetrieverImplTest.java b/sormas-backend/src/test/java/de/symeda/sormas/backend/patch/partial_retrieval/PartialRetrieverImplTest.java new file mode 100644 index 00000000000..37d39b755b6 --- /dev/null +++ b/sormas-backend/src/test/java/de/symeda/sormas/backend/patch/partial_retrieval/PartialRetrieverImplTest.java @@ -0,0 +1,338 @@ +package de.symeda.sormas.backend.patch.partial_retrieval; + +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import de.symeda.sormas.api.Disease; +import de.symeda.sormas.api.Language; +import de.symeda.sormas.api.caze.CaseDataDto; +import de.symeda.sormas.api.caze.Vaccine; +import de.symeda.sormas.api.epidata.EpiDataDto; +import de.symeda.sormas.api.exposure.ExposureDto; +import de.symeda.sormas.api.exposure.ExposureType; +import de.symeda.sormas.api.i18n.I18nProperties; +import de.symeda.sormas.api.immunization.ImmunizationDto; +import de.symeda.sormas.api.immunization.ImmunizationStatus; +import de.symeda.sormas.api.patch.partial_retrieval.*; +import de.symeda.sormas.api.person.PersonContactDetailDto; +import de.symeda.sormas.api.person.PersonContactDetailType; +import de.symeda.sormas.api.person.PersonDto; +import de.symeda.sormas.api.person.PersonReferenceDto; +import de.symeda.sormas.api.symptoms.SymptomsDto; +import de.symeda.sormas.api.user.UserReferenceDto; +import de.symeda.sormas.api.utils.YesNoUnknown; +import de.symeda.sormas.api.vaccination.VaccinationDto; +import de.symeda.sormas.backend.AbstractBeanTest; + +class PartialRetrieverImplTest extends AbstractBeanTest { + + @Test + void retrievePartialForDisplay_immunization_and_vaccine() { + // PREPARE + Disease disease = Disease.RESPIRATORY_SYNCYTIAL_VIRUS; + CaseDataDto originalCase = creator.createUnclassifiedCase(disease); + UserReferenceDto reportingUser = originalCase.getReportingUser(); + + ImmunizationDto immunizationDto = ImmunizationDto.build(originalCase.getPerson()); + immunizationDto.setRelatedCase(originalCase.toReference()); + immunizationDto.setImmunizationStatus(ImmunizationStatus.ACQUIRED); + immunizationDto.setReportingUser(reportingUser); + VaccinationDto vaccination = VaccinationDto.build(reportingUser); + vaccination.setVaccineName(Vaccine.COMIRNATY); + vaccination.setOtherVaccineName("actual vaccine name"); + immunizationDto.setVaccinations(List.of(vaccination)); + getImmunizationFacade().save(immunizationDto); + + String immunizationStatusFieldName = toFieldName(ImmunizationDto.I18N_PREFIX, ImmunizationDto.IMMUNIZATION_STATUS); + String vaccineCombinedFieldName = + toFieldName(VaccinationDto.I18N_PREFIX, String.format("(%s|%s)", VaccinationDto.VACCINE_NAME, VaccinationDto.OTHER_VACCINE_NAME)); + + // EXECUTE + DisplayablePartialRetrievalResponse actual = victim().retrievePartialForDisplay( + new PartialRetrievalRequest().setCaseUuid(originalCase.getUuid()) + .setFieldsToRetrieve(Set.of(immunizationStatusFieldName, vaccineCombinedFieldName))); + + // CHECK + DisplayableFieldInfo immunizationStatusFieldInfo = actual.getFieldInfoDictionary().get(immunizationStatusFieldName); + DisplayableFieldInfo vaccineNameFieldInfo = actual.getFieldInfoDictionary().get("Vaccination.vaccineName"); + DisplayableFieldInfo otherVaccineNameFieldInfo = actual.getFieldInfoDictionary().get("Vaccination.otherVaccineName"); + Assertions.assertAll( + () -> Assertions.assertTrue(actual.getFailuresDescriptions().isEmpty()), + + () -> Assertions.assertNotNull(immunizationStatusFieldInfo), + + () -> Assertions.assertEquals("Immunization status", immunizationStatusFieldInfo.getTranslatedFieldName()), + () -> Assertions.assertEquals("Acquired", immunizationStatusFieldInfo.getTranslatedFieldValue()), + + () -> Assertions.assertEquals("COMIRNATY", vaccineNameFieldInfo.getTranslatedFieldValue()), + () -> Assertions.assertEquals("actual vaccine name", otherVaccineNameFieldInfo.getTranslatedFieldValue()), + + () -> Assertions.assertEquals(3, actual.getFieldInfoDictionary().size())); + } + + @Test + void retrievePartialForDisplay() { + // PREPARE + I18nProperties.setUserLanguage(Language.FR); + Disease disease = Disease.AFP; + CaseDataDto originalCase = creator.createUnclassifiedCase(disease); + + String caseDiseaseFieldName = toFieldName(CaseDataDto.I18N_PREFIX, CaseDataDto.DISEASE); + + // EXECUTE + DisplayablePartialRetrievalResponse actual = victim().retrievePartialForDisplay( + new PartialRetrievalRequest().setCaseUuid(originalCase.getUuid()).setFieldsToRetrieve(Set.of(caseDiseaseFieldName))); + + // CHECK + DisplayableFieldInfo caseDiseaseFieldInfo = actual.getFieldInfoDictionary().get(caseDiseaseFieldName); + Assertions.assertAll( + () -> Assertions.assertTrue(actual.getFailuresDescriptions().isEmpty()), + + () -> Assertions.assertNotNull(caseDiseaseFieldInfo), + + () -> Assertions.assertEquals("Maladie", caseDiseaseFieldInfo.getTranslatedFieldName()), + () -> Assertions.assertEquals("Paralysie Flasque Aiguë", caseDiseaseFieldInfo.getTranslatedFieldValue())); + } + + @Test + void retrievePartial_german() { + // PREPARE + I18nProperties.setUserLanguage(Language.DE); + + Disease disease = Disease.PERTUSSIS; + CaseDataDto originalCase = creator.createUnclassifiedCase(disease); + + // EXECUTE + String clinicalConfirmation = toFieldName(CaseDataDto.I18N_PREFIX, CaseDataDto.CLINICAL_CONFIRMATION); + String symptomsAbdominalPain = toFieldName(SymptomsDto.I18N_PREFIX, SymptomsDto.ABDOMINAL_PAIN); + PartialRetrievalResponse actual = victim().retrievePartial( + new PartialRetrievalRequest().setCaseUuid(originalCase.getUuid()) + .setFieldsToRetrieve(Set.of(clinicalConfirmation, symptomsAbdominalPain))); + + // CHECK + FieldInfo caseDiseaseFieldInfo = actual.getFieldInfoDictionary().get(clinicalConfirmation); + FieldInfo symptomsAbdominalPainFieldInfo = actual.getFieldInfoDictionary().get(symptomsAbdominalPain); + Assertions.assertAll( + () -> Assertions.assertTrue(actual.getFailuresDictionary().isEmpty()), + + () -> Assertions.assertTrue(actual.getFieldInfoDictionary().containsKey(clinicalConfirmation)), + () -> Assertions.assertEquals("Klinische Bestätigung", caseDiseaseFieldInfo.getTranslatedFieldName()), + () -> Assertions.assertEquals(originalCase.getClinicalConfirmation(), caseDiseaseFieldInfo.getFieldValue()), + + () -> Assertions.assertTrue(actual.getFieldInfoDictionary().containsKey(symptomsAbdominalPain)), + () -> Assertions.assertEquals("Abdominalschmerzen", symptomsAbdominalPainFieldInfo.getTranslatedFieldName()), + () -> Assertions.assertEquals(originalCase.getSymptoms().getAbdominalPain(), symptomsAbdominalPainFieldInfo.getFieldValue())); + } + + @Test + void retrievePartial_person() { + // PREPARE + Disease disease = Disease.PERTUSSIS; + CaseDataDto originalCase = creator.createUnclassifiedCase(disease); + + PersonReferenceDto personRef = originalCase.getPerson(); + + PersonDto person = getPersonFacade().getByUuid(personRef.getUuid()); + + // EXECUTE + String personFirstNameFieldName = toFieldName(PersonDto.I18N_PREFIX, PersonDto.FIRST_NAME); + PartialRetrievalResponse actual = victim() + .retrievePartial(new PartialRetrievalRequest().setCaseUuid(originalCase.getUuid()).setFieldsToRetrieve(Set.of(personFirstNameFieldName))); + + // CHECK + FieldInfo personFirstNameFieldInfo = actual.getFieldInfoDictionary().get(personFirstNameFieldName); + Assertions.assertAll( + () -> Assertions.assertTrue(actual.getFailuresDictionary().isEmpty()), + () -> Assertions.assertTrue(actual.getFieldInfoDictionary().containsKey(personFirstNameFieldName)), + () -> Assertions.assertEquals("First name", personFirstNameFieldInfo.getTranslatedFieldName()), + () -> Assertions.assertEquals(person.getFirstName(), personFirstNameFieldInfo.getFieldValue())); + } + + @Test + void retrievePartial_null_value() { + // PREPARE + Disease disease = Disease.DENGUE; + CaseDataDto originalCase = creator.createUnclassifiedCase(disease); + + // EXECUTE + String caseFollowUpUntilFieldName = toFieldName(CaseDataDto.I18N_PREFIX, CaseDataDto.FOLLOW_UP_UNTIL); + PartialRetrievalResponse actual = victim().retrievePartial( + new PartialRetrievalRequest().setCaseUuid(originalCase.getUuid()).setFieldsToRetrieve(Set.of(caseFollowUpUntilFieldName))); + + System.out.println("actual = " + actual); + + // CHECK + FieldInfo followUpUntilFieldInfo = actual.getFieldInfoDictionary().get(caseFollowUpUntilFieldName); + Assertions.assertAll( + () -> Assertions.assertTrue(actual.getFailuresDictionary().isEmpty()), + () -> Assertions.assertTrue(actual.getFieldInfoDictionary().containsKey(caseFollowUpUntilFieldName)), + () -> Assertions.assertEquals("Follow-up until", followUpUntilFieldInfo.getTranslatedFieldName()), + () -> Assertions.assertNull(followUpUntilFieldInfo.getFieldValue())); + } + + @Test + void retrieve_contact_details_phone() { + // PREPARE + Disease disease = Disease.PERTUSSIS; + CaseDataDto originalCase = creator.createUnclassifiedCase(disease); + + PersonReferenceDto personRef = originalCase.getPerson(); + + PersonDto person = getPersonFacade().getByUuid(personRef.getUuid()); + List contactDetails = person.getPersonContactDetails(); + + PersonContactDetailDto primaryPhoneNumber = new PersonContactDetailDto(); + primaryPhoneNumber.setContactInformation("09876543"); + primaryPhoneNumber.setPersonContactDetailType(PersonContactDetailType.PHONE); + primaryPhoneNumber.setPrimaryContact(true); + contactDetails.add(primaryPhoneNumber); + + PersonContactDetailDto secondaryPhoneNumber = new PersonContactDetailDto(); + secondaryPhoneNumber.setContactInformation("12345678"); + secondaryPhoneNumber.setPersonContactDetailType(PersonContactDetailType.PHONE); + contactDetails.add(secondaryPhoneNumber); + + PersonContactDetailDto emptyPhone = new PersonContactDetailDto(); + emptyPhone.setContactInformation(" "); + emptyPhone.setPersonContactDetailType(PersonContactDetailType.PHONE); + contactDetails.add(emptyPhone); + + PersonContactDetailDto nullPhone = new PersonContactDetailDto(); + nullPhone.setContactInformation(null); + nullPhone.setPersonContactDetailType(PersonContactDetailType.PHONE); + contactDetails.add(nullPhone); + + getPersonFacade().save(person); + + // EXECUTE + String personContactDetails = + toFieldName(toFieldName(PersonDto.I18N_PREFIX, PersonDto.PERSON_CONTACT_DETAILS), PersonContactDetailDto.PHONE_NUMBER_TYPE); + PartialRetrievalResponse actual = victim() + .retrievePartial(new PartialRetrievalRequest().setCaseUuid(originalCase.getUuid()).setFieldsToRetrieve(Set.of(personContactDetails))); + + // CHECK + FieldInfo personFirstNameFieldInfo = actual.getFieldInfoDictionary().get(personContactDetails); + Assertions.assertAll( + () -> Assertions.assertTrue(actual.getFailuresDictionary().isEmpty()), + () -> Assertions.assertTrue(actual.getFieldInfoDictionary().containsKey(personContactDetails)), + () -> Assertions.assertEquals("Phone number type", personFirstNameFieldInfo.getTranslatedFieldName()), + () -> Assertions.assertEquals("09876543; 12345678", personFirstNameFieldInfo.getFieldValue())); + } + + @Test + void retrieve_contact_details_email() { + // PREPARE + Disease disease = Disease.RUBELLA; + CaseDataDto originalCase = creator.createUnclassifiedCase(disease); + + PersonReferenceDto personRef = originalCase.getPerson(); + + PersonDto person = getPersonFacade().getByUuid(personRef.getUuid()); + List contactDetails = person.getPersonContactDetails(); + + PersonContactDetailDto emailContactDetail = new PersonContactDetailDto(); + emailContactDetail.setContactInformation("mail@mail.ch"); + emailContactDetail.setPersonContactDetailType(PersonContactDetailType.EMAIL); + contactDetails.add(emailContactDetail); + + PersonContactDetailDto phoneContactDetail = new PersonContactDetailDto(); + phoneContactDetail.setContactInformation("MUST_NOT_BE_RETRIEVED"); + phoneContactDetail.setPersonContactDetailType(PersonContactDetailType.PHONE); + contactDetails.add(phoneContactDetail); + + getPersonFacade().save(person); + + // EXECUTE + String personContactDetails = toFieldName(PersonContactDetailDto.I18N_PREFIX, PersonContactDetailDto.CONTACT_INFORMATION); + PartialRetrievalResponse actual = victim() + .retrievePartial(new PartialRetrievalRequest().setCaseUuid(originalCase.getUuid()).setFieldsToRetrieve(Set.of(personContactDetails))); + + // CHECK + FieldInfo personFirstNameFieldInfo = actual.getFieldInfoDictionary().get(personContactDetails); + Assertions.assertAll( + () -> Assertions.assertTrue(actual.getFailuresDictionary().isEmpty()), + () -> Assertions.assertTrue(actual.getFieldInfoDictionary().containsKey(personContactDetails)), + () -> Assertions.assertEquals("Contact information", personFirstNameFieldInfo.getTranslatedFieldName()), + () -> Assertions.assertEquals("mail@mail.ch", personFirstNameFieldInfo.getFieldValue())); + } + + @Test + void retrievePartial_epiData_twoFields() { + // PREPARE + Disease disease = Disease.PERTUSSIS; + CaseDataDto originalCase = creator.createUnclassifiedCase(disease); + + originalCase.getEpiData().setExposureDetailsKnown(YesNoUnknown.YES); + originalCase.getEpiData().setContactWithSourceCaseKnown(YesNoUnknown.NO); + getCaseFacade().save(originalCase); + + String exposureDetailsKnownFieldName = toFieldName(EpiDataDto.I18N_PREFIX, EpiDataDto.EXPOSURE_DETAILS_KNOWN); + String contactWithSourceCaseKnownFieldName = toFieldName(EpiDataDto.I18N_PREFIX, EpiDataDto.CONTACT_WITH_SOURCE_CASE_KNOWN); + + // EXECUTE + PartialRetrievalResponse actual = victim().retrievePartial( + new PartialRetrievalRequest().setCaseUuid(originalCase.getUuid()) + .setFieldsToRetrieve(Set.of(exposureDetailsKnownFieldName, contactWithSourceCaseKnownFieldName))); + + // CHECK + FieldInfo exposureDetailsKnownFieldInfo = actual.getFieldInfoDictionary().get(exposureDetailsKnownFieldName); + FieldInfo contactWithSourceCaseKnownFieldInfo = actual.getFieldInfoDictionary().get(contactWithSourceCaseKnownFieldName); + Assertions.assertAll( + () -> Assertions.assertTrue(actual.getFailuresDictionary().isEmpty()), + + () -> Assertions.assertTrue(actual.getFieldInfoDictionary().containsKey(exposureDetailsKnownFieldName)), + () -> Assertions.assertEquals("Exposure details known", exposureDetailsKnownFieldInfo.getTranslatedFieldName()), + () -> Assertions.assertEquals(YesNoUnknown.YES, exposureDetailsKnownFieldInfo.getFieldValue()), + + () -> Assertions.assertTrue(actual.getFieldInfoDictionary().containsKey(contactWithSourceCaseKnownFieldName)), + () -> Assertions.assertEquals("Contacts with source case known", contactWithSourceCaseKnownFieldInfo.getTranslatedFieldName()), + () -> Assertions.assertEquals(YesNoUnknown.NO, contactWithSourceCaseKnownFieldInfo.getFieldValue())); + } + + @Test + void retrievePartial_epiData_exposureType() { + // PREPARE + Disease disease = Disease.PERTUSSIS; + CaseDataDto originalCase = creator.createUnclassifiedCase(disease); + + ExposureDto exposure = ExposureDto.build(ExposureType.WORK); + originalCase.getEpiData().getExposures().add(exposure); + getCaseFacade().save(originalCase); + + String exposureTypeFieldName = toFieldName(ExposureDto.I18N_PREFIX, ExposureDto.EXPOSURE_TYPE); + + // EXECUTE + PartialRetrievalResponse actual = victim() + .retrievePartial(new PartialRetrievalRequest().setCaseUuid(originalCase.getUuid()).setFieldsToRetrieve(Set.of(exposureTypeFieldName))); + + // CHECK + FieldInfo exposureTypeFieldInfo = actual.getFieldInfoDictionary().get(exposureTypeFieldName); + Assertions.assertAll( + () -> Assertions.assertTrue(actual.getFailuresDictionary().isEmpty()), + () -> Assertions.assertTrue(actual.getFieldInfoDictionary().containsKey(exposureTypeFieldName)), + () -> Assertions.assertEquals("Type of activity", exposureTypeFieldInfo.getTranslatedFieldName()), + () -> Assertions.assertEquals(ExposureType.WORK, exposureTypeFieldInfo.getFieldValue())); + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.CsvSource({ + "bats, Bats", + "exposureDetailsKnown, Exposure details known", + "admissionDate, Admission date", + "firstName, First name", + "myUntranslatedField, My untranslated field" }) + void humanizeCamelCase_variousInputs_returnsHumanReadableLabel(String input, String expected) { + Assertions.assertEquals(expected.strip(), PartialRetrieverImpl.humanizeCamelCase(input)); + } + + private static String toFieldName(String prefix, String fieldName) { + return prefix + '.' + fieldName; + } + + private PartialRetriever victim() { + return getPartialRetriever(); + } +} diff --git a/sormas-backend/src/test/java/de/symeda/sormas/backend/util/CollectorUtilsTest.java b/sormas-backend/src/test/java/de/symeda/sormas/backend/util/CollectorUtilsTest.java new file mode 100644 index 00000000000..3f37372eb68 --- /dev/null +++ b/sormas-backend/src/test/java/de/symeda/sormas/backend/util/CollectorUtilsTest.java @@ -0,0 +1,158 @@ +package de.symeda.sormas.backend.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; + +import de.symeda.sormas.backend.AbstractUnitTest; + +class CollectorUtilsTest extends AbstractUnitTest { + + private final Item item1 = new Item("key1", "value1"); + private final Item item2 = new Item("key2", null); + private final Item item3 = new Item(null, "value3"); + private final Item item4 = new Item(null, null); + + @Test + void toNullSafeMap_normalKeysAndValues_createsCorrectMap() { + // PREPARE + List items = List.of(item1); + + // EXECUTE + Map result = items.stream().collect(CollectorUtils.toNullSafeMap(Item::getKey, Item::getValue)); + + // CHECK + assertEquals(mapOf("key1", "value1"), result); + } + + @Test + void toNullSafeMap_nullValues_preservesNulls() { + // PREPARE + List items = List.of(item2); + + // EXECUTE + Map result = items.stream().collect(CollectorUtils.toNullSafeMap(Item::getKey, Item::getValue)); + + // CHECK + assertEquals(mapOf("key2", null), result); + } + + @Test + void toNullSafeMap_nullKeys_preservesNulls() { + // PREPARE + List items = List.of(item3); + + // EXECUTE + Map result = items.stream().collect(CollectorUtils.toNullSafeMap(Item::getKey, Item::getValue)); + + // CHECK + assertEquals(mapOf(null, "value3"), result); + } + + @Test + void toNullSafeMap_nullKeyAndValue_preservesNulls() { + // PREPARE + List items = List.of(item4); + + // EXECUTE + Map result = items.stream().collect(CollectorUtils.toNullSafeMap(Item::getKey, Item::getValue)); + + // CHECK + Object v1 = null; + Object k1 = null; + assertEquals(mapOf(k1, v1), result); + } + + private static @NotNull Map mapOf(Object key, Object value) { + HashMap hashMap = new HashMap<>(); + hashMap.put(key, value); + return hashMap; + } + + @Test + void toNullSafeMap_multipleItems_handlesCollisions() { + // PREPARE + List items = List.of( + new Item("key1", "value1"), + new Item("key1", "value2"), // collision + new Item("key2", null), + new Item(null, "value3")); + + // EXECUTE + Map result = items.stream().collect(CollectorUtils.toNullSafeMap(Item::getKey, Item::getValue)); + + // CHECK + assertEquals("value2", result.get("key1")); // last write wins + assertNull(result.get("key2")); + assertEquals("value3", result.get(null)); + } + + @Test + void toNullSafeMap_parallelStream_preservesNulls() { + // PREPARE + List items = List.of(item1, item2, item3); + + // EXECUTE + Map result = items.parallelStream().collect(CollectorUtils.toNullSafeMap(Item::getKey, Item::getValue)); + + // CHECK + assertEquals("value1", result.get("key1")); + assertNull(result.get("key2")); + assertEquals("value3", result.get(null)); + } + + @Test + void toNullSafeMap_emptyStream_returnsEmptyMap() { + // PREPARE + List items = List.of(); + + // EXECUTE + Map result = items.stream().collect(CollectorUtils.toNullSafeMap(Item::getKey, Item::getValue)); + + // CHECK + assertTrue(result.isEmpty()); + } + + @Test + void toNullSafeMap_handlesNullsWhereStandardFails() { + // PREPARE + List items = List.of(item2); // contains null value + + // EXECUTE + Map nullSafeResult = items.stream().collect(CollectorUtils.toNullSafeMap(Item::getKey, Item::getValue)); + + // CHECK + assertEquals(mapOf("key2", null), nullSafeResult); + + assertThrows(NullPointerException.class, () -> items.stream().collect(Collectors.toMap(Item::getKey, Item::getValue))); + } + + // Helper class for tests + static class Item { + + private final String key; + private final String value; + + Item(String key, String value) { + this.key = key; + this.value = value; + } + + String getKey() { + return key; + } + + String getValue() { + return value; + } + } +} diff --git a/sormas-backend/src/test/java/de/symeda/sormas/patch/DataPatcherImplTest.java b/sormas-backend/src/test/java/de/symeda/sormas/patch/DataPatcherImplTest.java new file mode 100644 index 00000000000..3c15966e147 --- /dev/null +++ b/sormas-backend/src/test/java/de/symeda/sormas/patch/DataPatcherImplTest.java @@ -0,0 +1,1034 @@ +package de.symeda.sormas.patch; + +import java.sql.Date; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.Mockito; + +import de.symeda.sormas.api.Disease; +import de.symeda.sormas.api.Language; +import de.symeda.sormas.api.activityascase.ActivityAsCaseDto; +import de.symeda.sormas.api.activityascase.ActivityAsCaseType; +import de.symeda.sormas.api.caze.CaseDataDto; +import de.symeda.sormas.api.caze.Vaccine; +import de.symeda.sormas.api.customizableenum.CustomizableEnumTranslation; +import de.symeda.sormas.api.customizableenum.CustomizableEnumType; +import de.symeda.sormas.api.exposure.ExposureDto; +import de.symeda.sormas.api.exposure.ExposureType; +import de.symeda.sormas.api.hospitalization.HospitalizationDto; +import de.symeda.sormas.api.hospitalization.HospitalizationReasonType; +import de.symeda.sormas.api.hospitalization.PreviousHospitalizationDto; +import de.symeda.sormas.api.immunization.ImmunizationDto; +import de.symeda.sormas.api.immunization.ImmunizationStatus; +import de.symeda.sormas.api.infrastructure.country.CountryDto; +import de.symeda.sormas.api.infrastructure.country.CountryReferenceDto; +import de.symeda.sormas.api.infrastructure.facility.FacilityDto; +import de.symeda.sormas.api.patch.*; +import de.symeda.sormas.api.person.*; +import de.symeda.sormas.api.symptoms.SymptomState; +import de.symeda.sormas.api.utils.YesNoUnknown; +import de.symeda.sormas.api.vaccination.VaccinationDto; +import de.symeda.sormas.backend.AbstractBeanTest; +import de.symeda.sormas.backend.MockProducer; +import de.symeda.sormas.backend.common.ConfigFacadeEjb; +import de.symeda.sormas.backend.customizableenum.CustomizableEnumValue; + +class DataPatcherImplTest extends AbstractBeanTest { + + @Test + void patch_noErrorsReplaceAlways() { + // PREPARE + CaseDataDto originalCase = creator.createUnclassifiedCase(Disease.PERTUSSIS); + + String newLastname = "toto"; + String newSequelaeDetails = "Some very interesting sequelaeDetails"; + CaseDataPatchRequest request = new CaseDataPatchRequest().setCaseUuid(originalCase.getUuid()) + .setReplacementStrategy(DataReplacementStrategy.ALWAYS) + .setPatchDictionary( + Map.of( + "Person.lastName", + newLastname, + + "CaseData.sequelaeDetails", + newSequelaeDetails)); + + // EXECUTE + DataPatchResponse response = victim().patch(request); + + // CHECK + CaseDataDto actualCase = getCaseFacade().getByUuid(originalCase.getUuid()); + PersonDto actualPerson = getPersonFacade().getByUuid(originalCase.getPerson().getUuid()); + + Assertions.assertAll( + () -> Assertions.assertTrue(response.getFailures().isEmpty(), "Failure found, but should be empty"), + // PERSON + () -> Assertions.assertEquals(newLastname, actualPerson.getLastName()), + // CASE + () -> Assertions.assertEquals(newSequelaeDetails, actualCase.getSequelaeDetails())); + } + + @Test + void patch_aliasUsage() { + // PREPARE + CaseDataDto originalCase = creator.createUnclassifiedCase(Disease.PERTUSSIS); + + CaseDataPatchRequest request = new CaseDataPatchRequest().setCaseUuid(originalCase.getUuid()) + .setReplacementStrategy(DataReplacementStrategy.ALWAYS) + .setPatchDictionary(Map.of("Symptoms.cough", "YES")); + + // EXECUTE + DataPatchResponse response = victim().patch(request); + + // CHECK + + CaseDataDto actual = getCaseFacade().getByUuid(originalCase.getUuid()); + + Assertions.assertAll( + () -> Assertions.assertTrue(response.getFailures().isEmpty(), "Failure found, but should be empty"), + // PERSON + () -> Assertions.assertEquals(SymptomState.YES, actual.getSymptoms().getCough())); + } + + @Test + void patch_noErrorsReplaceAlwaysPersonContactDetails() { + // PREPARE + CaseDataDto originalCase = creator.createUnclassifiedCase(Disease.PERTUSSIS); + + String newPhoneNumber = "123654687"; + String newEmail = "name@email.de"; + String origin = "ngSurvey"; + CaseDataPatchRequest request = new CaseDataPatchRequest().setCaseUuid(originalCase.getUuid()) + .setReplacementStrategy(DataReplacementStrategy.ALWAYS) + .setPatchDictionary( + Map.of( + "PersonContactDetail.contactInformation", + newEmail, + + "Person.personContactDetails.phoneNumberType", + newPhoneNumber)) + .setOrigin(origin); + + // EXECUTE + DataPatchResponse response = victim().patch(request); + + // CHECK + + PersonDto actualPerson = getPersonFacade().getByUuid(originalCase.getPerson().getUuid()); + + Supplier> contactDetailsStreamProvider = () -> actualPerson.getPersonContactDetails().stream(); + Assertions.assertAll( + () -> Assertions.assertTrue(response.getFailures().isEmpty(), "Failure found, but should be empty"), + // PERSON + () -> Assertions + .assertTrue(contactDetailsStreamProvider.get().allMatch(contactDetail -> origin.equals(contactDetail.getAdditionalInformation()))), + + () -> Assertions.assertTrue( + contactDetailsStreamProvider.get() + .anyMatch( + contactDetail -> contactDetail.getPersonContactDetailType() == PersonContactDetailType.PHONE + && newPhoneNumber.equals(contactDetail.getContactInformation()) + && contactDetail.getPhoneNumberType() == PhoneNumberType.OTHER)), + + () -> Assertions.assertTrue( + contactDetailsStreamProvider.get() + .anyMatch( + contactDetail -> contactDetail.getPersonContactDetailType() == PersonContactDetailType.EMAIL + && newEmail.equals(contactDetail.getContactInformation())))); + } + + @ParameterizedTest + @ValueSource(strings = { + "Task", + "Event", + "ExternalMessage" }) + void patch_invalidPrefix(String prefix) { + // PREPARE + CaseDataDto originalCase = creator.createUnclassifiedCase(Disease.RESPIRATORY_SYNCYTIAL_VIRUS); + + String ignoredValue = "ignoredValue"; + + Map patchDictionary = Map.of(prefix + ".reportingUser", ignoredValue + + ); + // EXECUTE + DataPatchResponse response = + victim().patch(new CaseDataPatchRequest().setCaseUuid(originalCase.getUuid()).setPatchDictionary(patchDictionary)); + + // CHECK + Map expectedFailures = buildDictionaryOfFailureType(patchDictionary, DataPatchFailureCause.UNSUPPORTED_PREFIX); + + Assertions.assertAll( + () -> Assertions.assertTrue(response.getValidPatchDictionary().isEmpty(), "Nothing should have been patched, should be empty"), + // FAILURES + () -> Assertions.assertEquals(expectedFailures, response.getFailures())); + } + + @Test + void patch_forbiddenField() { + // PREPARE + CaseDataDto originalCase = creator.createUnclassifiedCase(Disease.RESPIRATORY_SYNCYTIAL_VIRUS); + + String ignoredValue = "ignoredValue"; + + Map patchDictionary = Map.of( + "Person.birthdate", + ignoredValue, + + "Person.birthdateDD", + ignoredValue, + "Person.birthdateMM", + ignoredValue, + "Person.birthdateYYYY", + ignoredValue); + // EXECUTE + DataPatchResponse response = + victim().patch(new CaseDataPatchRequest().setCaseUuid(originalCase.getUuid()).setPatchDictionary(patchDictionary)); + + // CHECK + Map expectedFailures = buildDictionaryOfFailureType(patchDictionary, DataPatchFailureCause.FORBIDDEN_FIELD); + + Assertions.assertAll( + () -> Assertions.assertTrue(response.getValidPatchDictionary().isEmpty(), "Nothing should have been patched, should be empty"), + // FAILURES + () -> Assertions.assertEquals(expectedFailures, response.getFailures())); + } + + @ParameterizedTest + @ValueSource(strings = { + // DE + " weibLiCH ", + // exact match + "Weiblich", + "WEIblïch", + // FR + "Féminin ", + // EN, + // ENUM exact match + "FEMALE", + " femaLe " }) + void patch_enum(String femaleValue) { + // PREPARE + CaseDataDto originalCase = creator.createUnclassifiedCase(Disease.PERTUSSIS); + + CaseDataPatchRequest request = new CaseDataPatchRequest().setCaseUuid(originalCase.getUuid()) + .setReplacementStrategy(DataReplacementStrategy.ALWAYS) + .setPatchDictionary(Map.of("Person.sex", femaleValue)) + .setInputLanguages(List.of(Language.DE, Language.EN, Language.FR)); + + // EXECUTE + DataPatchResponse response = victim().patch(request); + + // CHECK + + PersonDto actualPerson = getPersonFacade().getByUuid(originalCase.getPerson().getUuid()); + + Assertions.assertAll( + () -> Assertions.assertTrue(response.getFailures().isEmpty(), "Failure found, but should be empty"), + // PERSON + + () -> Assertions.assertEquals(Sex.FEMALE, actualPerson.getSex())); + } + + @Test + void patch_invalidMultiFieldFormat() { + // PREPARE + CaseDataDto originalCase = creator.createUnclassifiedCase(Disease.RESPIRATORY_SYNCYTIAL_VIRUS); + + String ignoredValue = "ignoredValue"; + + Map patchDictionary = Map.of( + "Person.(deathDate", + ignoredValue, + + "Person.((deathDate))", + ignoredValue, + + "Person.(deathDate)", + ignoredValue, + + "Person.(deathDate|wefuiohjwerf", + ignoredValue, + + "Person.(deathDate))", + ignoredValue, + + "Person.()", + ignoredValue, + + "Person.)deathDate(", + ignoredValue); + // EXECUTE + DataPatchResponse response = + victim().patch(new CaseDataPatchRequest().setCaseUuid(originalCase.getUuid()).setPatchDictionary(patchDictionary)); + + // CHECK + Map expectedFailures = + buildDictionaryOfFailureType(patchDictionary, DataPatchFailureCause.INVALID_MULTIPLE_FIELDS_FORMAT); + + Assertions.assertAll( + () -> Assertions.assertTrue(response.getValidPatchDictionary().isEmpty(), "Nothing should have been patched, should be empty"), + // FAILURES + () -> Assertions.assertEquals(expectedFailures, response.getFailures())); + } + + // the value is properly resolved and set into the person, but when fetching it again it's not there anymore. + @Test + void patch_customizableEnu_default_enum() { + // PREPARE + OccupationType.getDefaultValues().forEach((k, v) -> { + CustomizableEnumValue entry = new CustomizableEnumValue(); + entry.setDataType(CustomizableEnumType.OCCUPATION_TYPE); + entry.setValue(k); + entry.setCaption(k); + entry.setProperties(v); + entry.setDefaultValue(true); + getCustomizableEnumValueService().ensurePersisted(entry); + }); + + getCustomizableEnumFacade().loadData(); + + String healthcareWorker = "HEALTHCARE_WORKER"; + OccupationType expectedOccupationType = + getCustomizableEnumFacade().getEnumValue(CustomizableEnumType.OCCUPATION_TYPE, null, healthcareWorker); + + CaseDataDto originalCase = creator.createUnclassifiedCase(Disease.PERTUSSIS); + + FacilityDto healthFacility = getFacilityFacade().getByUuid(originalCase.getHealthFacility().getUuid()); + originalCase.setDistrict(healthFacility.getDistrict()); + getCaseFacade().save(originalCase); + + // must be able to ignore accents - whitespaces - case + String input = "Im Gesundheitswesen tätig"; + Map patchDictionary = Map.of("Person.occupationType", input); + CaseDataPatchRequest request = new CaseDataPatchRequest().setCaseUuid(originalCase.getUuid()) + .setReplacementStrategy(DataReplacementStrategy.ALWAYS) + .setPatchDictionary(patchDictionary) + .setInputLanguages(List.of(Language.DE, Language.EN, Language.FR));; + + Mockito.when(MockProducer.getCustomizableEnumFacadeForConverter().getEnumValue(CustomizableEnumType.OCCUPATION_TYPE, null, healthcareWorker)) + .thenReturn(expectedOccupationType); + + // EXECUTE + DataPatchResponse response = victim().patch(request); + + PersonDto person = getPersonFacade().getByUuid(originalCase.getPerson().getUuid()); + + // CHECK + + Assertions.assertAll( + () -> Assertions.assertTrue(response.getFailures().isEmpty(), "Failure found, but should be empty"), + + () -> Assertions.assertEquals(expectedOccupationType, person.getOccupationType()), + + () -> Assertions.assertEquals(patchDictionary, response.getValidPatchDictionary())); + } + + @Test + void patch_customizableEnu_default_enum_other() { + // PREPARE + OccupationType.getDefaultValues().forEach((k, v) -> { + CustomizableEnumValue entry = new CustomizableEnumValue(); + entry.setDataType(CustomizableEnumType.OCCUPATION_TYPE); + entry.setValue(k); + entry.setCaption(k); + entry.setProperties(v); + entry.setDefaultValue(true); + getCustomizableEnumValueService().ensurePersisted(entry); + }); + + getCustomizableEnumFacade().loadData(); + + String otherOccupationType = "OTHER"; + OccupationType expectedOccupationType = + getCustomizableEnumFacade().getEnumValue(CustomizableEnumType.OCCUPATION_TYPE, null, otherOccupationType); + + CustomizableEnumValue customizableEnumValue = getCustomizableEnumValueService().getAll() + .stream() + .filter(enumMember -> otherOccupationType.equals(enumMember.getValue())) + .findAny() + .orElseThrow(); + + CaseDataDto originalCase = creator.createUnclassifiedCase(Disease.PERTUSSIS); + + FacilityDto healthFacility = getFacilityFacade().getByUuid(originalCase.getHealthFacility().getUuid()); + originalCase.setDistrict(healthFacility.getDistrict()); + getCaseFacade().save(originalCase); + + // must be able to ignore accents - whitespaces - case + String input = "DOES NOT MATCH TO Anythign"; + Map patchDictionary = Map.of("Person.(occupationType|occupationDetails|additionalDetails)", input); + CaseDataPatchRequest request = new CaseDataPatchRequest().setCaseUuid(originalCase.getUuid()) + .setReplacementStrategy(DataReplacementStrategy.ALWAYS) + .setPatchDictionary(patchDictionary); + + Mockito + .when(MockProducer.getCustomizableEnumFacadeForConverter().getEnumValue(CustomizableEnumType.OCCUPATION_TYPE, null, otherOccupationType)) + .thenReturn(expectedOccupationType); + + // EXECUTE + DataPatchResponse response = victim().patch(request); + + PersonDto person = getPersonFacade().getByUuid(originalCase.getPerson().getUuid()); + + // CHECK + + Assertions.assertAll( + () -> Assertions.assertTrue(response.getFailures().isEmpty(), "Failure found, but should be empty"), + + () -> Assertions.assertEquals(expectedOccupationType, person.getOccupationType()), + () -> Assertions.assertEquals(input, person.getOccupationDetails()), + + () -> Assertions.assertEquals( + Map.of("Person.occupationType", input, "Person.occupationDetails", input, "Person.additionalDetails", input), + response.getValidPatchDictionary())); + } + + private static @NotNull CustomizableEnumTranslation buildTranslation(String en, String irrelated) { + CustomizableEnumTranslation e1 = new CustomizableEnumTranslation(); + e1.setLanguageCode(en); + e1.setValue(irrelated); + return e1; + } + + @Test + void patch_referenceData_country() { + // PREPARE + MockProducer.getProperties().setProperty(ConfigFacadeEjb.COUNTRY_LOCALE, "lu"); + + CountryDto dto = new CountryDto(); + dto.setIsoCode("DEU"); + dto.setDefaultName("Germany"); + CountryDto germanyReferenceDto = getCountryFacade().save(dto); + + CaseDataDto originalCase = creator.createUnclassifiedCase(Disease.PERTUSSIS); + + // must be able to ignore accents - whitespaces - case + Map patchDictionary = Map.of("Person.birthCountry", " Deutschländ "); + CaseDataPatchRequest request = new CaseDataPatchRequest().setCaseUuid(originalCase.getUuid()) + .setReplacementStrategy(DataReplacementStrategy.ALWAYS) + .setPatchDictionary(patchDictionary) + .setInputLanguages(List.of(Language.DE, Language.EN, Language.FR)); + + // EXECUTE + DataPatchResponse response = victim().patch(request); + + PersonDto actualPerson = getPersonFacade().getByUuid(originalCase.getPerson().getUuid()); + + // CHECK + + Assertions.assertAll( + () -> Assertions.assertTrue(response.getFailures().isEmpty(), "Failure found, but should be empty"), + + () -> Assertions.assertEquals( + new CountryReferenceDto(germanyReferenceDto.getUuid(), germanyReferenceDto.getIsoCode()), + actualPerson.getBirthCountry())); + } + + @Test + void patch_notSupportedForDisease() { + // PREPARE + CaseDataDto originalCase = creator.createUnclassifiedCase(Disease.RESPIRATORY_SYNCYTIAL_VIRUS); + + String ignoredValue = "ignoredValue"; + + Map patchDictionary = Map.of("CaseData.plagueType", ignoredValue); + + // EXECUTE + DataPatchResponse response = + victim().patch(new CaseDataPatchRequest().setCaseUuid(originalCase.getUuid()).setPatchDictionary(patchDictionary)); + + // CHECK + Map expectedFailures = + buildDictionaryOfFailureType(patchDictionary, DataPatchFailureCause.UNSUPPORTED_FIELD_FOR_DISEASE_OR_COUNTRY_OR_FEATURE); + + Assertions.assertAll( + () -> Assertions.assertTrue(response.getValidPatchDictionary().isEmpty(), "Nothing should have been patched, should be empty"), + // FAILURES + () -> Assertions.assertEquals(expectedFailures, response.getFailures())); + } + + @Test + void patch_notSupportedForCountry() { + // PREPARE + CaseDataDto originalCase = creator.createUnclassifiedCase(Disease.RESPIRATORY_SYNCYTIAL_VIRUS); + + String ignoredValue = "ignoredValue"; + + Map patchDictionary = Map.of("CaseData.quarantineOrderedOfficialDocumentDate", ignoredValue); + + // EXECUTE + DataPatchResponse response = + victim().patch(new CaseDataPatchRequest().setCaseUuid(originalCase.getUuid()).setPatchDictionary(patchDictionary)); + + // CHECK + Map expectedFailures = + buildDictionaryOfFailureType(patchDictionary, DataPatchFailureCause.UNSUPPORTED_FIELD_FOR_DISEASE_OR_COUNTRY_OR_FEATURE); + + Assertions.assertAll( + () -> Assertions.assertTrue(response.getValidPatchDictionary().isEmpty(), "Nothing should have been patched, should be empty"), + // FAILURES + () -> Assertions.assertEquals(expectedFailures, response.getFailures())); + } + + @Test + void patch_notSupportedFeature() { + // PREPARE + CaseDataDto originalCase = creator.createUnclassifiedCase(Disease.RESPIRATORY_SYNCYTIAL_VIRUS); + + String ignoredValue = "ignoredValue"; + + Map patchDictionary = Map.of("CaseData.caseReferenceNumber", ignoredValue); + + // EXECUTE + DataPatchResponse response = + victim().patch(new CaseDataPatchRequest().setCaseUuid(originalCase.getUuid()).setPatchDictionary(patchDictionary)); + + // CHECK + Map expectedFailures = + buildDictionaryOfFailureType(patchDictionary, DataPatchFailureCause.UNSUPPORTED_FIELD_FOR_DISEASE_OR_COUNTRY_OR_FEATURE); + + Assertions.assertAll( + () -> Assertions.assertTrue(response.getValidPatchDictionary().isEmpty(), "Nothing should have been patched, should be empty"), + // FAILURES + () -> Assertions.assertEquals(expectedFailures, response.getFailures())); + } + + private static @NotNull Map buildDictionaryOfFailureType( + Map patchDictionary, + DataPatchFailureCause unsupportedFieldForDisease) { + return patchDictionary.entrySet() + .stream() + .map( + entry -> Map.entry( + entry.getKey(), + new DataPatchFailure().setDataPatchFailureCause(unsupportedFieldForDisease).setProvidedFieldValue(entry.getValue()))) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + } + + @Test + void patch_patchInCaseOfFailureTrue() { + // PREPARE + CaseDataDto originalCase = creator.createUnclassifiedCase(Disease.RESPIRATORY_SYNCYTIAL_VIRUS); + + String ignoredValue = "ignoredValue"; + + String trueString = " ja "; + Map patchDictionary = + Map.of("CaseData.symptoms.cough", trueString, "CaseData.quarantineOrderedOfficialDocumentDate", ignoredValue); + + CaseDataPatchRequest request = new CaseDataPatchRequest().setPatchedInCaseOfFailures(true) + .setCaseUuid(originalCase.getUuid()) + .setPatchDictionary(patchDictionary) + .setInputLanguages(List.of(Language.DE)); + + // EXECUTE + DataPatchResponse response = victim().patch(request); + + CaseDataDto actual = getCaseFacade().getByUuid(originalCase.getUuid()); + + // CHECK + Map expectedFailures = buildDictionaryOfFailureType( + Map.of("CaseData.quarantineOrderedOfficialDocumentDate", ignoredValue), + DataPatchFailureCause.UNSUPPORTED_FIELD_FOR_DISEASE_OR_COUNTRY_OR_FEATURE); + + Assertions.assertAll( + () -> Assertions.assertTrue(response.isApplied()), + + () -> Assertions.assertEquals(Map.of("CaseData.symptoms.cough", trueString), response.getValidPatchDictionary()), + + () -> Assertions.assertEquals(SymptomState.YES, actual.getSymptoms().getCough()), + + () -> Assertions.assertNull(actual.getQuarantineOrderedOfficialDocumentDate()), + + // FAILURES + () -> Assertions.assertEquals(expectedFailures, response.getFailures())); + } + + @Test + void patch_noPatchInCaseOfFailureFalse() { + // PREPARE + CaseDataDto originalCase = creator.createUnclassifiedCase(Disease.RESPIRATORY_SYNCYTIAL_VIRUS); + + String ignoredValue = "ignoredValue"; + + String trueString = " ja "; + Map patchDictionary = + Map.of("CaseData.symptoms.cough", trueString, "CaseData.quarantineOrderedOfficialDocumentDate", ignoredValue); + + CaseDataPatchRequest request = new CaseDataPatchRequest().setPatchedInCaseOfFailures(false) + .setCaseUuid(originalCase.getUuid()) + .setPatchDictionary(patchDictionary) + .setInputLanguages(List.of(Language.DE)); + + // EXECUTE + DataPatchResponse response = victim().patch(request); + + CaseDataDto actual = getCaseFacade().getByUuid(originalCase.getUuid()); + + // CHECK + Map expectedFailures = buildDictionaryOfFailureType( + Map.of("CaseData.quarantineOrderedOfficialDocumentDate", ignoredValue), + DataPatchFailureCause.UNSUPPORTED_FIELD_FOR_DISEASE_OR_COUNTRY_OR_FEATURE); + + Assertions.assertAll( + () -> Assertions.assertFalse(response.isApplied()), + + () -> Assertions.assertEquals(Map.of("CaseData.symptoms.cough", trueString), response.getValidPatchDictionary()), + + () -> Assertions.assertNull(actual.getSymptoms().getCough()), + + () -> Assertions.assertNull(actual.getQuarantineOrderedOfficialDocumentDate()), + + // FAILURES + () -> Assertions.assertEquals(expectedFailures, response.getFailures())); + } + + @Test + void patch_replacementMode_null_value() { + // PREPARE + CaseDataDto originalCase = creator.createUnclassifiedCase(Disease.RESPIRATORY_SYNCYTIAL_VIRUS); + originalCase.setQuarantineChangeComment("some non empty value"); + + getCaseFacade().save(originalCase); + + Assertions.assertFalse(originalCase.getSymptoms().getSymptomatic()); + + String ignoredValue = "ignoredValue"; + + Map patchDictionary = new HashMap<>(); + patchDictionary.put("CaseData.quarantineChangeComment", null); + + CaseDataPatchRequest request = new CaseDataPatchRequest().setReplacementStrategy(DataReplacementStrategy.ALWAYS) + .setEmptyValueBehavior(EmptyValueBehavior.REPLACE) + .setCaseUuid(originalCase.getUuid()) + .setPatchDictionary(patchDictionary); + + // EXECUTE + DataPatchResponse response = victim().patch(request); + + CaseDataDto actual = getCaseFacade().getByUuid(originalCase.getUuid()); + + // CHECK + Assertions.assertAll( + () -> Assertions.assertTrue(response.isApplied()), + + () -> Assertions.assertEquals(patchDictionary, response.getValidPatchDictionary()), + + () -> Assertions.assertNull(actual.getQuarantineChangeComment()), + + // FAILURES + () -> Assertions.assertEquals(Map.of(), response.getFailures())); + } + + @Test + void patch_replacementMode_null_value_error() { + // PREPARE + CaseDataDto originalCase = creator.createUnclassifiedCase(Disease.RESPIRATORY_SYNCYTIAL_VIRUS); + String expectedQuarantineChangeComment = "some non empty value"; + originalCase.setQuarantineChangeComment(expectedQuarantineChangeComment); + + getCaseFacade().save(originalCase); + + Assertions.assertFalse(originalCase.getSymptoms().getSymptomatic()); + + String ignoredValue = "ignoredValue"; + + Map patchDictionary = new HashMap<>(); + patchDictionary.put("CaseData.quarantineChangeComment", null); + + CaseDataPatchRequest request = new CaseDataPatchRequest().setReplacementStrategy(DataReplacementStrategy.ALWAYS) + .setEmptyValueBehavior(EmptyValueBehavior.IGNORE) + .setCaseUuid(originalCase.getUuid()) + .setPatchDictionary(patchDictionary); + + // EXECUTE + DataPatchResponse response = victim().patch(request); + + CaseDataDto actual = getCaseFacade().getByUuid(originalCase.getUuid()); + + // CHECK + Assertions.assertAll( + () -> Assertions.assertFalse(response.isApplied()), + + () -> Assertions.assertEquals(Map.of(), response.getValidPatchDictionary()), + + () -> Assertions.assertEquals(expectedQuarantineChangeComment, actual.getQuarantineChangeComment()), + + // FAILURES + () -> Assertions.assertEquals(Map.of(), response.getFailures())); + } + + @Test + void patch_fieldDoesNoExist() { + // PREPARE + CaseDataDto originalCase = creator.createUnclassifiedCase(Disease.RESPIRATORY_SYNCYTIAL_VIRUS); + + String ignoredValue = "ignoredValue"; + + Map patchDictionary = new HashMap<>(); + patchDictionary.put("CaseData.NON_EXISTING_FIELD", "validValue"); + + CaseDataPatchRequest request = new CaseDataPatchRequest().setCaseUuid(originalCase.getUuid()).setPatchDictionary(patchDictionary); + + // EXECUTE + DataPatchResponse response = victim().patch(request); + + Map expectedFailures = buildDictionaryOfFailureType(patchDictionary, DataPatchFailureCause.FIELD_DOES_NOT_EXIST); + + // CHECK + Assertions.assertAll( + () -> Assertions.assertFalse(response.isApplied()), + + () -> Assertions.assertEquals(expectedFailures, response.getFailures()), + + () -> Assertions.assertEquals(Map.of(), response.getValidPatchDictionary())); + } + + @Test + void patch_epiData() { + // PREPARE + CaseDataDto originalCase = creator.createUnclassifiedCase(Disease.PERTUSSIS); + + CaseDataPatchRequest request = new CaseDataPatchRequest().setCaseUuid(originalCase.getUuid()) + .setReplacementStrategy(DataReplacementStrategy.ALWAYS) + .setPatchDictionary( + Map.of( + "EpiData.exposureDetailsKnown", + "YES", + + "EpiData.contactWithSourceCaseKnown", + "NO")); + + // EXECUTE + DataPatchResponse response = victim().patch(request); + + // CHECK + + CaseDataDto actualCase = getCaseFacade().getByUuid(originalCase.getUuid()); + + Assertions.assertAll( + () -> Assertions.assertTrue(response.getFailures().isEmpty(), "Failure found, but should be empty"), + () -> Assertions.assertEquals(YesNoUnknown.YES, actualCase.getEpiData().getExposureDetailsKnown()), + () -> Assertions.assertEquals(YesNoUnknown.NO, actualCase.getEpiData().getContactWithSourceCaseKnown())); + } + + @Test + void patch_ifNotAlreadyPresent_sameDayDifferentTime_noForbiddenValueOverride() { + // PREPARE + CaseDataDto originalCase = creator.createUnclassifiedCase(Disease.PERTUSSIS); + + // Set classificationDate to 08:30 on 2024-06-15 — a non-midnight timestamp on the same calendar day that will be patched + java.util.Date existingDate = java.util.Date.from(LocalDateTime.of(2024, 6, 15, 12, 30, 0).atZone(ZoneId.systemDefault()).toInstant()); + originalCase.setReportDate(existingDate); + getCaseFacade().save(originalCase); + + // Patch with the same calendar day as a plain date string — DatePatchMapper resolves this to midnight (00:00:00), + // which differs in time from existingDate. Without DateEqualityChecker this would trigger FORBIDDEN_VALUE_OVERRIDE. + String patchDate = "2024-06-15"; + CaseDataPatchRequest request = new CaseDataPatchRequest().setCaseUuid(originalCase.getUuid()) + .setPatchDictionary(Map.of(toFieldName(CaseDataDto.I18N_PREFIX, CaseDataDto.REPORT_DATE), patchDate)); + + // EXECUTE + DataPatchResponse response = victim().patch(request); + + // CHECK + Assertions.assertAll( + () -> Assertions.assertTrue(response.getFailures().isEmpty(), "FORBIDDEN_VALUE_OVERRIDE must not fire for same-day dates"), + () -> Assertions.assertTrue(response.isApplied())); + } + + @Test + void patch_hospitalization() { + // PREPARE + CaseDataDto originalCase = creator.createUnclassifiedCase(Disease.DENGUE); + + // EXECUTE + DataPatchResponse response = victim().patch( + new CaseDataPatchRequest().setCaseUuid(originalCase.getUuid()) + .setReplacementStrategy(DataReplacementStrategy.ALWAYS) + .setPatchDictionary( + Map.of( + // YesNoUnknown enum + toFieldName(HospitalizationDto.I18N_PREFIX, HospitalizationDto.ADMITTED_TO_HEALTH_FACILITY), + "YES", + + // Date + toFieldName(HospitalizationDto.I18N_PREFIX, HospitalizationDto.ADMISSION_DATE), + "2024-05-10", + + // String + toFieldName(HospitalizationDto.I18N_PREFIX, HospitalizationDto.DESCRIPTION), + "patient admitted urgently", + + // HospitalizationReasonType enum + toFieldName(HospitalizationDto.I18N_PREFIX, HospitalizationDto.HOSPITALIZATION_REASON), + "ISOLATION"))); + + // CHECK + + CaseDataDto actualCase = getCaseFacade().getByUuid(originalCase.getUuid()); + Assertions.assertAll( + () -> Assertions.assertTrue(response.getFailures().isEmpty(), "Failures: " + response.getFailures()), + () -> Assertions.assertEquals(YesNoUnknown.YES, actualCase.getHospitalization().getAdmittedToHealthFacility()), + () -> Assertions.assertEquals( + Date.from(LocalDate.parse("2024-05-10").atStartOfDay(ZoneId.systemDefault()).toInstant()), + actualCase.getHospitalization().getAdmissionDate()), + () -> Assertions.assertEquals("patient admitted urgently", actualCase.getHospitalization().getDescription()), + () -> Assertions.assertEquals(HospitalizationReasonType.ISOLATION, actualCase.getHospitalization().getHospitalizationReason())); + } + + @Test + void patch_vaccination_only() { + // PREPARE + Disease disease = Disease.RESPIRATORY_SYNCYTIAL_VIRUS; + CaseDataDto originalCase = creator.createUnclassifiedCase(disease); + + // EXECUTE + DataPatchResponse response = victim().patch( + new CaseDataPatchRequest().setCaseUuid(originalCase.getUuid()) + .setReplacementStrategy(DataReplacementStrategy.ALWAYS) + .setPatchDictionary(Map.of("Vaccination.vaccineName", "COMIRNATY"))); + + // CHECK + List immunizations = getImmunizationFacade().getByPersonUuids(List.of(originalCase.getPerson().getUuid())); + Assertions.assertAll( + () -> Assertions.assertTrue(response.getFailures().isEmpty(), "Failures: " + response.getFailures()), + () -> Assertions.assertTrue(response.isApplied()), + () -> Assertions.assertEquals(1, immunizations.size()), + () -> Assertions.assertEquals(1, immunizations.get(0).getVaccinations().size()), + () -> Assertions.assertEquals(Vaccine.COMIRNATY, immunizations.get(0).getVaccinations().get(0).getVaccineName())); + } + + @Test + void patch_vaccination_and_immunization() { + // PREPARE + Disease disease = Disease.DENGUE; + CaseDataDto originalCase = creator.createUnclassifiedCase(disease); + + // EXECUTE + DataPatchResponse response = victim().patch( + new CaseDataPatchRequest().setCaseUuid(originalCase.getUuid()) + .setReplacementStrategy(DataReplacementStrategy.ALWAYS) + .setPatchDictionary( + Map.of( + "Vaccination.vaccineName", + "COMIRNATY", + + "Immunization.immunizationStatus", + "ACQUIRED"))); + + // CHECK + List immunizations = getImmunizationFacade().getByPersonUuids(List.of(originalCase.getPerson().getUuid())); + Assertions.assertAll( + () -> Assertions.assertTrue(response.getFailures().isEmpty(), "Failures: " + response.getFailures()), + () -> Assertions.assertTrue(response.isApplied()), + () -> Assertions.assertEquals(1, immunizations.size()), + () -> Assertions.assertEquals(ImmunizationStatus.ACQUIRED, immunizations.get(0).getImmunizationStatus()), + () -> Assertions.assertEquals(1, immunizations.get(0).getVaccinations().size()), + () -> Assertions.assertEquals(Vaccine.COMIRNATY, immunizations.get(0).getVaccinations().get(0).getVaccineName())); + } + + @Test + void patch_vaccination_and_immunization_with_existing_creates_new_without_override() { + // PREPARE + Disease disease = Disease.PERTUSSIS; + CaseDataDto originalCase = creator.createUnclassifiedCase(disease); + + ImmunizationDto existingImmunization = ImmunizationDto.build(originalCase.getPerson()); + existingImmunization.setRelatedCase(originalCase.toReference()); + existingImmunization.setImmunizationStatus(ImmunizationStatus.NOT_ACQUIRED); + existingImmunization.setReportingUser(originalCase.getReportingUser()); + VaccinationDto existingVaccination = VaccinationDto.build(originalCase.getReportingUser()); + existingVaccination.setVaccineName(Vaccine.COMIRNATY); + existingImmunization.setVaccinations(List.of(existingVaccination)); + getImmunizationFacade().save(existingImmunization); + + // EXECUTE — patch with ALWAYS strategy creates new immunization + vaccination, never modifies existing ones + DataPatchResponse response = victim().patch( + new CaseDataPatchRequest().setCaseUuid(originalCase.getUuid()) + .setReplacementStrategy(DataReplacementStrategy.ALWAYS) + .setPatchDictionary( + Map.of( + "Vaccination.vaccineName", + "MRNA_1273", + + "Immunization.immunizationStatus", + "ACQUIRED"))); + + // CHECK + List immunizations = getImmunizationFacade().getByPersonUuids(List.of(originalCase.getPerson().getUuid())); + Assertions.assertAll( + () -> Assertions.assertTrue(response.getFailures().isEmpty(), "Failures: " + response.getFailures()), + () -> Assertions.assertTrue(response.isApplied()), + + // Two immunizations: original unchanged + new one from patch + () -> Assertions.assertEquals(2, immunizations.size()), + + // Original immunization is untouched + () -> Assertions.assertTrue(immunizations.stream().anyMatch(imm -> ImmunizationStatus.NOT_ACQUIRED.equals(imm.getImmunizationStatus()))), + + // New immunization was created with the patched status + () -> Assertions.assertTrue(immunizations.stream().anyMatch(imm -> ImmunizationStatus.ACQUIRED.equals(imm.getImmunizationStatus()))), + + // Original vaccination (COMIRNATY) is still there + () -> Assertions.assertTrue( + immunizations.stream() + .flatMap(imm -> imm.getVaccinations().stream()) + .anyMatch(vac -> Vaccine.COMIRNATY.equals(vac.getVaccineName()))), + + // New vaccination (MRNA_1273) was created by the patch + () -> Assertions.assertTrue( + immunizations.stream() + .flatMap(imm -> imm.getVaccinations().stream()) + .anyMatch(vac -> Vaccine.MRNA_1273.equals(vac.getVaccineName())))); + } + + @Test + void patch_exposure() { + // PREPARE + Disease disease = Disease.DENGUE; + CaseDataDto originalCase = creator.createUnclassifiedCase(disease); + + // EXECUTE + DataPatchResponse response = victim().patch( + new CaseDataPatchRequest().setCaseUuid(originalCase.getUuid()) + .setReplacementStrategy(DataReplacementStrategy.ALWAYS) + .setPatchDictionary( + Map.of( + toFieldName(ExposureDto.I18N_PREFIX, ExposureDto.EXPOSURE_TYPE), + "WORK", + toFieldName(ExposureDto.I18N_PREFIX, ExposureDto.DESCRIPTION), + "market visit"))); + + // CHECK + CaseDataDto actualCase = getCaseFacade().getByUuid(originalCase.getUuid()); + List exposures = actualCase.getEpiData().getExposures(); + Assertions.assertAll( + () -> Assertions.assertTrue(response.getFailures().isEmpty(), "Failures: " + response.getFailures()), + () -> Assertions.assertTrue(response.isApplied()), + () -> Assertions.assertEquals(1, exposures.size()), + () -> Assertions.assertEquals(ExposureType.WORK, exposures.get(0).getExposureType()), + () -> Assertions.assertEquals("market visit", exposures.get(0).getDescription())); + } + + @Test + void patch_previousHospitalization() { + // PREPARE + Disease disease = Disease.DENGUE; + CaseDataDto originalCase = creator.createUnclassifiedCase(disease); + String facilityCaption = originalCase.getHealthFacility().getCaption(); + + // EXECUTE + DataPatchResponse response = victim().patch( + new CaseDataPatchRequest().setCaseUuid(originalCase.getUuid()) + .setReplacementStrategy(DataReplacementStrategy.ALWAYS) + .setPatchDictionary( + Map.of( + toFieldName(PreviousHospitalizationDto.I18N_PREFIX, PreviousHospitalizationDto.ADMITTED_TO_HEALTH_FACILITY), + "YES", + + toFieldName(PreviousHospitalizationDto.I18N_PREFIX, PreviousHospitalizationDto.ADMISSION_DATE), + "2024-03-15", + + toFieldName(PreviousHospitalizationDto.I18N_PREFIX, PreviousHospitalizationDto.ICU_LENGTH_OF_STAY), + "7"))); + + // CHECK + CaseDataDto actualCase = getCaseFacade().getByUuid(originalCase.getUuid()); + List previousHospitalizations = actualCase.getHospitalization().getPreviousHospitalizations(); + Assertions.assertAll( + () -> Assertions.assertTrue(response.getFailures().isEmpty(), "Failures: " + response.getFailures()), + () -> Assertions.assertTrue(response.isApplied()), + () -> Assertions.assertEquals(1, previousHospitalizations.size()), + () -> Assertions.assertEquals(YesNoUnknown.YES, previousHospitalizations.get(0).getAdmittedToHealthFacility()), + () -> Assertions.assertEquals( + Date.from(LocalDate.parse("2024-03-15").atStartOfDay(ZoneId.systemDefault()).toInstant()), + previousHospitalizations.get(0).getAdmissionDate()), + () -> Assertions.assertEquals(7, previousHospitalizations.get(0).getIcuLengthOfStay())); + } + + @Test + void patch_previousHospitalization_admissionAndDischargeDates() { + // PREPARE + Disease disease = Disease.DENGUE; + CaseDataDto originalCase = creator.createUnclassifiedCase(disease); + + String admissionDate = "2026-02-02"; + String dischargeDate = "2026-02-04"; + + // EXECUTE + DataPatchResponse response = victim().patch( + new CaseDataPatchRequest().setCaseUuid(originalCase.getUuid()) + .setPatchDictionary( + Map.of( + toFieldName(PreviousHospitalizationDto.I18N_PREFIX, PreviousHospitalizationDto.ADMISSION_DATE), + admissionDate, + + toFieldName(PreviousHospitalizationDto.I18N_PREFIX, PreviousHospitalizationDto.DISCHARGE_DATE), + dischargeDate))); + + // CHECK + + CaseDataDto actualCase = getCaseFacade().getByUuid(originalCase.getUuid()); + List previousHospitalizations = actualCase.getHospitalization().getPreviousHospitalizations(); + Assertions.assertAll( + () -> Assertions.assertTrue(response.getFailures().isEmpty(), "Failures: " + response.getFailures()), + () -> Assertions.assertTrue(response.isApplied()), + () -> Assertions.assertEquals(1, previousHospitalizations.size()), + () -> Assertions.assertEquals( + Date.from(LocalDate.parse(admissionDate).atStartOfDay(ZoneId.systemDefault()).toInstant()), + previousHospitalizations.get(0).getAdmissionDate()), + () -> Assertions.assertEquals( + Date.from(LocalDate.parse(dischargeDate).atStartOfDay(ZoneId.systemDefault()).toInstant()), + previousHospitalizations.get(0).getDischargeDate())); + } + + @Test + void patch_activityAsCase() { + // PREPARE + Disease disease = Disease.PERTUSSIS; + CaseDataDto originalCase = creator.createUnclassifiedCase(disease); + + // EXECUTE + DataPatchResponse response = victim().patch( + new CaseDataPatchRequest().setCaseUuid(originalCase.getUuid()) + .setReplacementStrategy(DataReplacementStrategy.ALWAYS) + .setPatchDictionary( + Map.of( + toFieldName(ActivityAsCaseDto.I18N_PREFIX, ActivityAsCaseDto.ACTIVITY_AS_CASE_TYPE), + "WORK", + toFieldName(ActivityAsCaseDto.I18N_PREFIX, ActivityAsCaseDto.DESCRIPTION), + "office work"))); + + // CHECK + CaseDataDto actualCase = getCaseFacade().getByUuid(originalCase.getUuid()); + List activities = actualCase.getEpiData().getActivitiesAsCase(); + Assertions.assertAll( + () -> Assertions.assertTrue(response.getFailures().isEmpty(), "Failures: " + response.getFailures()), + () -> Assertions.assertTrue(response.isApplied()), + () -> Assertions.assertEquals(1, activities.size()), + () -> Assertions.assertEquals(ActivityAsCaseType.WORK, activities.get(0).getActivityAsCaseType()), + () -> Assertions.assertEquals("office work", activities.get(0).getDescription())); + } + + private static String toFieldName(String prefix, String field) { + return prefix + '.' + field; + } + + private DataPatcher victim() { + return getCaseDataPatcher(); + } +} diff --git a/sormas-backend/src/test/resources/logback-test.xml b/sormas-backend/src/test/resources/logback-test.xml index 16b3c8e2134..fd72b627179 100644 --- a/sormas-backend/src/test/resources/logback-test.xml +++ b/sormas-backend/src/test/resources/logback-test.xml @@ -6,43 +6,43 @@ - - - %date %-5level \(%C.java:%L\) - %msg%n + + + %date %-5level \(%C.java:%L\) - %msg%n - - + + - + - + - - + + - + - + - + - + - + - - + + - + - - + + diff --git a/sormas-rest/src/main/java/de/symeda/sormas/rest/resources/LabMessageResource.java b/sormas-rest/src/main/java/de/symeda/sormas/rest/resources/LabMessageResource.java index bf26812b830..543c2643577 100644 --- a/sormas-rest/src/main/java/de/symeda/sormas/rest/resources/LabMessageResource.java +++ b/sormas-rest/src/main/java/de/symeda/sormas/rest/resources/LabMessageResource.java @@ -15,12 +15,7 @@ package de.symeda.sormas.rest.resources; -import javax.ws.rs.GET; -import javax.ws.rs.POST; -import javax.ws.rs.Path; -import javax.ws.rs.PathParam; -import javax.ws.rs.Produces; -import javax.ws.rs.QueryParam; +import javax.ws.rs.*; import javax.ws.rs.core.MediaType; import de.symeda.sormas.api.FacadeProvider; diff --git a/sormas-rest/src/main/webapp/WEB-INF/glassfish-web.xml b/sormas-rest/src/main/webapp/WEB-INF/glassfish-web.xml index 6097973ad7e..80356e3ce76 100644 --- a/sormas-rest/src/main/webapp/WEB-INF/glassfish-web.xml +++ b/sormas-rest/src/main/webapp/WEB-INF/glassfish-web.xml @@ -858,6 +858,11 @@ EXTERNAL_MESSAGE_DOCTOR_DECLARATION_VIEW + + EXTERNAL_MESSAGE_SURVEY_RESPONSE_VIEW + EXTERNAL_MESSAGE_SURVEY_RESPONSE_VIEW + + EXTERNAL_MESSAGE_LABORATORY_PROCESS EXTERNAL_MESSAGE_LABORATORY_PROCESS @@ -868,6 +873,12 @@ EXTERNAL_MESSAGE_DOCTOR_DECLARATION_PROCESS + + + EXTERNAL_MESSAGE_SURVEY_RESPONSE_PROCESS + EXTERNAL_MESSAGE_SURVEY_RESPONSE_PROCESS + + EXTERNAL_MESSAGE_LABORATORY_DELETE EXTERNAL_MESSAGE_LABORATORY_DELETE @@ -878,6 +889,11 @@ EXTERNAL_MESSAGE_DOCTOR_DECLARATION_DELETE + + EXTERNAL_MESSAGE_SURVEY_RESPONSE_DELETE + EXTERNAL_MESSAGE_SURVEY_RESPONSE_DELETE + + TRAVEL_ENTRY_MANAGEMENT_ACCESS TRAVEL_ENTRY_MANAGEMENT_ACCESS diff --git a/sormas-rest/src/main/webapp/WEB-INF/web.xml b/sormas-rest/src/main/webapp/WEB-INF/web.xml index 36e8b47bf06..cea4e109cca 100644 --- a/sormas-rest/src/main/webapp/WEB-INF/web.xml +++ b/sormas-rest/src/main/webapp/WEB-INF/web.xml @@ -696,6 +696,10 @@ EXTERNAL_MESSAGE_DOCTOR_DECLARATION_VIEW + + EXTERNAL_MESSAGE_SURVEY_RESPONSE_VIEW + + EXTERNAL_MESSAGE_LABORATORY_PROCESS @@ -704,6 +708,10 @@ EXTERNAL_MESSAGE_DOCTOR_DECLARATION_PROCESS + + EXTERNAL_MESSAGE_SURVEY_RESPONSE_PROCESS + + EXTERNAL_MESSAGE_LABORATORY_DELETE @@ -712,6 +720,10 @@ EXTERNAL_MESSAGE_DOCTOR_DECLARATION_DELETE + + EXTERNAL_MESSAGE_SURVEY_RESPONSE_DELETE + + TRAVEL_ENTRY_MANAGEMENT_ACCESS diff --git a/sormas-ui/src/main/java/de/symeda/sormas/ui/externalmessage/ExternalMessageController.java b/sormas-ui/src/main/java/de/symeda/sormas/ui/externalmessage/ExternalMessageController.java index da60b16c9eb..711574e19df 100644 --- a/sormas-ui/src/main/java/de/symeda/sormas/ui/externalmessage/ExternalMessageController.java +++ b/sormas-ui/src/main/java/de/symeda/sormas/ui/externalmessage/ExternalMessageController.java @@ -84,6 +84,8 @@ import de.symeda.sormas.ui.externalmessage.labmessage.LabMessageSlider; import de.symeda.sormas.ui.externalmessage.labmessage.RelatedLabMessageHandler; import de.symeda.sormas.ui.externalmessage.physiciansreport.PhysiciansReportProcessingFlow; +import de.symeda.sormas.ui.externalmessage.surveyresponse.SurveyResponseDetailsWindow; +import de.symeda.sormas.ui.externalmessage.surveyresponse.SurveyResponseFailureEditor; import de.symeda.sormas.ui.utils.ButtonHelper; import de.symeda.sormas.ui.utils.CssStyles; import de.symeda.sormas.ui.utils.DeleteRestoreHandlers; @@ -129,6 +131,13 @@ public void showLabMessagesSlider(List labMessages) { public void showExternalMessage(String messageUuid, boolean withActions, Runnable onFormActionPerformed) { ExternalMessageDto newDto = FacadeProvider.getExternalMessageFacade().getByUuid(messageUuid); + + if (ExternalMessageType.SURVEY_RESPONSE.equals(newDto.getType())) { + // Side effect: window will be added to the current window. + new SurveyResponseDetailsWindow(newDto, onFormActionPerformed); + return; + } + VerticalLayout layout = new VerticalLayout(); layout.setMargin(true); @@ -151,6 +160,38 @@ public void showExternalMessage(String messageUuid, boolean withActions, Runnabl form.setValue(newDto); } + public void processSurveyResponse(String surveyResponseMessageUuid) { + ExternalMessageDto externalMessage = FacadeProvider.getExternalMessageFacade().getByUuid(surveyResponseMessageUuid); + + de.symeda.sormas.api.externalmessage.survey.ExternalMessageSurveyResponseResult result = + externalMessage.getSurveyResponseData() != null && externalMessage.getSurveyResponseData().getLatest() != null + ? externalMessage.getSurveyResponseData().getLatest().getResult() + : null; + + if (result == null) { + Notification.show(I18nProperties.getString(Strings.messageSurveyResponseNotYetProcessed), Notification.Type.HUMANIZED_MESSAGE); + return; + } + + if (result.getPatchResponse() != null && result.getPatchResponse().hasFailures()) { + de.symeda.sormas.api.patch.partial_retrieval.DisplayablePartialRetrievalResponse displayData; + try { + displayData = FacadeProvider.getExternalMessageFacade().fetchSurveyResponseFieldsForDisplay(surveyResponseMessageUuid); + } catch (Exception e) { + logger.error("Error retrieving survey response fields for display", e); + displayData = new de.symeda.sormas.api.patch.partial_retrieval.DisplayablePartialRetrievalResponse(); + } + + final de.symeda.sormas.api.patch.partial_retrieval.DisplayablePartialRetrievalResponse finalDisplayData = displayData; + SurveyResponseFailureEditor editor = new SurveyResponseFailureEditor(externalMessage, finalDisplayData, () -> { + SormasUI.get().getNavigator().navigateTo(ExternalMessagesView.VIEW_NAME); + }); + UI.getCurrent().addWindow(editor); + } else { + Notification.show(I18nProperties.getString(Strings.messageSurveyResponseAllFieldsApplied), Notification.Type.HUMANIZED_MESSAGE); + } + } + public void processLabMessage(String labMessageUuid) { ExternalMessageDto labMessage = FacadeProvider.getExternalMessageFacade().getByUuid(labMessageUuid); ExternalMessageProcessingFacade processingFacade = getExternalMessageProcessingFacade(); @@ -510,6 +551,9 @@ private void bulkEditAssignee(Collection selectedRows, if (UiUtil.permitted(UserRight.EXTERNAL_MESSAGE_DOCTOR_DECLARATION_PROCESS)) { types.add(ExternalMessageType.PHYSICIANS_REPORT); } + if (UiUtil.permitted(UserRight.EXTERNAL_MESSAGE_SURVEY_RESPONSE_PROCESS)) { + types.add(ExternalMessageType.SURVEY_RESPONSE); + } components.syncUsersForMessageType(types); components.getAssignMeButton().addClickListener(e -> { diff --git a/sormas-ui/src/main/java/de/symeda/sormas/ui/externalmessage/ExternalMessageGrid.java b/sormas-ui/src/main/java/de/symeda/sormas/ui/externalmessage/ExternalMessageGrid.java index bd07b1f0d53..741049ecb6b 100644 --- a/sormas-ui/src/main/java/de/symeda/sormas/ui/externalmessage/ExternalMessageGrid.java +++ b/sormas-ui/src/main/java/de/symeda/sormas/ui/externalmessage/ExternalMessageGrid.java @@ -71,6 +71,7 @@ public class ExternalMessageGrid extends FilteredGrid dataProviderListener; @@ -186,7 +187,10 @@ private HorizontalLayout buildAssigneeLayout(ExternalMessageIndexDto externalMes final boolean canAssignDoctorDeclaration = ExternalMessageType.PHYSICIANS_REPORT.equals(externalMessage.getType()) && UiUtil.permitted(UserRight.EXTERNAL_MESSAGE_DOCTOR_DECLARATION_PROCESS); - if (canAssignLabMessage || canAssignDoctorDeclaration) { + final boolean canAssignSurveyResponse = ExternalMessageType.SURVEY_RESPONSE.equals(externalMessage.getType()) + && UiUtil.permitted(UserRight.EXTERNAL_MESSAGE_SURVEY_RESPONSE_PROCESS); + + if (canAssignLabMessage || canAssignDoctorDeclaration || canAssignSurveyResponse) { Button button = new Button(); CssStyles.style(button, ValoTheme.BUTTON_LINK, CssStyles.BUTTON_COMPACT); if (externalMessage.getAssignee() == null) { @@ -210,13 +214,19 @@ private Component buildProcessComponent(ExternalMessageIndexDto indexDto) { && UiUtil.permitted(UserRight.EXTERNAL_MESSAGE_DOCTOR_DECLARATION_PROCESS) && UiUtil.permitted(UserRight.CASE_CREATE, UserRight.CASE_EDIT); - if ((canAssignLabMessage || canAssignDoctorDeclaration) && indexDto.getStatus().isProcessable()) { + final boolean canAssignSurveyResponse = ExternalMessageType.SURVEY_RESPONSE.equals(indexDto.getType()) + && UiUtil.permitted(UserRight.EXTERNAL_MESSAGE_SURVEY_RESPONSE_PROCESS) + && UiUtil.permitted(UserRight.CASE_CREATE, UserRight.CASE_EDIT); + + if ((canAssignLabMessage || canAssignDoctorDeclaration || canAssignSurveyResponse) && indexDto.getStatus().isProcessable()) { // build process button return ButtonHelper.createButton(Captions.externalMessageProcess, e -> { if (ExternalMessageType.LAB_MESSAGE == indexDto.getType()) { ControllerProvider.getExternalMessageController().processLabMessage(indexDto.getUuid()); } else if (ExternalMessageType.PHYSICIANS_REPORT == indexDto.getType()) { ControllerProvider.getExternalMessageController().processDoctorDeclarationMessage(indexDto.getUuid()); + } else if (ExternalMessageType.SURVEY_RESPONSE == indexDto.getType()) { + ControllerProvider.getExternalMessageController().processSurveyResponse(indexDto.getUuid()); } }, ValoTheme.BUTTON_PRIMARY); } else { @@ -227,19 +237,30 @@ private Component buildProcessComponent(ExternalMessageIndexDto indexDto) { } } - private Button buildDownloadButton(ExternalMessageIndexDto labMessage) { + private Button buildDownloadButton(ExternalMessageIndexDto externalMessageIndex) { Button downloadButton = new Button(VaadinIcons.DOWNLOAD); downloadButton.setDescription(I18nProperties.getString(Strings.headingExternalMessageDownload)); - final String fileName = - String.format(XML_FILENAME_FORMAT, DataHelper.getShortUuid(labMessage.getUuid()), DateHelper.formatDateForExport(new Date())); + + String fileName; + String mimeType; + if (externalMessageIndex.getType() == ExternalMessageType.SURVEY_RESPONSE) { + fileName = String + .format(SURVEY_FILENAME_FORMAT, DataHelper.getShortUuid(externalMessageIndex.getUuid()), DateHelper.formatDateForExport(new Date())); + + mimeType = "application/json"; + } else { + fileName = String + .format(XML_FILENAME_FORMAT, DataHelper.getShortUuid(externalMessageIndex.getUuid()), DateHelper.formatDateForExport(new Date())); + mimeType = "application/xml"; + } StreamResource streamResource = new StreamResource( () -> ControllerProvider.getExternalMessageController() - .downloadExternalMessageAttachment(labMessage.getUuid()) + .downloadExternalMessageAttachment(externalMessageIndex.getUuid()) .map(ByteArrayInputStream::new) .orElse(null), fileName); - streamResource.setMIMEType("application/xml"); + streamResource.setMIMEType(mimeType); FileDownloader fileDownloader = new FileDownloader(streamResource); fileDownloader.extend(downloadButton); diff --git a/sormas-ui/src/main/java/de/symeda/sormas/ui/externalmessage/ExternalMessagesView.java b/sormas-ui/src/main/java/de/symeda/sormas/ui/externalmessage/ExternalMessagesView.java index 02f196f0901..7bd81346df9 100644 --- a/sormas-ui/src/main/java/de/symeda/sormas/ui/externalmessage/ExternalMessagesView.java +++ b/sormas-ui/src/main/java/de/symeda/sormas/ui/externalmessage/ExternalMessagesView.java @@ -1,10 +1,6 @@ package de.symeda.sormas.ui.externalmessage; -import java.util.ArrayList; -import java.util.Date; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import java.util.*; import java.util.function.Consumer; import javax.annotation.Nullable; @@ -12,14 +8,7 @@ import com.vaadin.icons.VaadinIcons; import com.vaadin.navigator.ViewChangeListener; import com.vaadin.server.Page; -import com.vaadin.ui.Alignment; -import com.vaadin.ui.Button; -import com.vaadin.ui.HorizontalLayout; -import com.vaadin.ui.Label; -import com.vaadin.ui.MenuBar; -import com.vaadin.ui.Notification; -import com.vaadin.ui.VerticalLayout; -import com.vaadin.ui.Window; +import com.vaadin.ui.*; import com.vaadin.ui.themes.ValoTheme; import com.vaadin.v7.data.Validator; @@ -40,18 +29,13 @@ import de.symeda.sormas.ui.SormasUI; import de.symeda.sormas.ui.UiUtil; import de.symeda.sormas.ui.ViewModelProviders; -import de.symeda.sormas.ui.utils.AbstractView; -import de.symeda.sormas.ui.utils.ButtonHelper; -import de.symeda.sormas.ui.utils.CssStyles; +import de.symeda.sormas.ui.utils.*; import de.symeda.sormas.ui.utils.DateTimeField; -import de.symeda.sormas.ui.utils.FutureDateValidator; -import de.symeda.sormas.ui.utils.LayoutUtil; -import de.symeda.sormas.ui.utils.MenuBarHelper; -import de.symeda.sormas.ui.utils.VaadinUiUtil; -import de.symeda.sormas.ui.utils.ViewConfiguration; public class ExternalMessagesView extends AbstractView { + private static final String SURVEY_FETCH_BUTTON_ENABLED = "SURVEY_FETCH_BUTTON_ENABLED"; + public static final String VIEW_NAME = "messages"; private final ViewConfiguration viewConfiguration; @@ -82,10 +66,20 @@ public ExternalMessagesView() { if (FacadeProvider.getFeatureConfigurationFacade().isPropertyValueTrue(FeatureType.EXTERNAL_MESSAGES, FeatureTypeProperty.FETCH_MODE)) { addHeaderComponent(ButtonHelper.createIconButton(Captions.externalMessageFetch, VaadinIcons.REFRESH, e -> { - checkForConcurrentEventsAndFetch(); + checkForConcurrentEventsAndFetch(false); }, ValoTheme.BUTTON_PRIMARY)); } + if (FacadeProvider.getFeatureConfigurationFacade() + .isPropertyValueTrue(FeatureType.EXTERNAL_MESSAGES, FeatureTypeProperty.SURVEY_FETCH_ENABLED)) { + addHeaderComponent( + ButtonHelper.createIconButton( + Captions.surveyFetch, + VaadinIcons.REFRESH, + e -> checkForConcurrentEventsAndFetch(true), + ValoTheme.BUTTON_PRIMARY)); + } + if (isBulkEditAllowed()) { btnEnterBulkEditMode = ButtonHelper.createIconButton(Captions.actionEnterBulkEditMode, VaadinIcons.CHECK_SQUARE_O, e -> { enterBulkEditMode(); @@ -266,7 +260,7 @@ private Button createAndAddStatusButton(@Nullable ExternalMessageStatus status, return button; } - private void checkForConcurrentEventsAndFetch() { + private void checkForConcurrentEventsAndFetch(boolean surveyMode) { boolean fetchAlreadyStarted = FacadeProvider.getSystemEventFacade().existsStartedEvent(SystemEventType.FETCH_EXTERNAL_MESSAGES); if (fetchAlreadyStarted) { VaadinUiUtil.showConfirmationPopup( @@ -277,20 +271,30 @@ private void checkForConcurrentEventsAndFetch() { 480, confirmed -> { if (confirmed) { - askForSinceDateAndFetch(); + askForSinceDateAndFetch(surveyMode); } }); } else { - askForSinceDateAndFetch(); + askForSinceDateAndFetch(surveyMode); } } - private void askForSinceDateAndFetch() { + private void askForSinceDateAndFetch(boolean surveyMode) { boolean atLeastOneFetchExecuted = FacadeProvider.getSyncFacade().hasAtLeastOneSuccessfullSyncOf(SystemEventType.FETCH_EXTERNAL_MESSAGES); if (atLeastOneFetchExecuted) { - fetchExternalMessages(null); + if (surveyMode) { + fetchSurveyMessages(null); + } else { + fetchExternalMessages(null); + } } else { - showSinceDateSelectionWindow(this::fetchExternalMessages); + showSinceDateSelectionWindow(since -> { + if (surveyMode) { + fetchSurveyMessages(since); + } else { + fetchExternalMessages(since); + } + }); } } @@ -305,6 +309,11 @@ private void fetchExternalMessages(Date since) { } } + private void fetchSurveyMessages(Date since) { + FacadeProvider.getExternalMessageFacade().saveAndProcessSurveyResponses(since); + grid.reload(); + } + private void showSinceDateSelectionWindow(Consumer dateConsumer) { VerticalLayout verticalLayout = new VerticalLayout(); Label label = new Label(I18nProperties.getString(Strings.confirmationSinceExternalMessages)); diff --git a/sormas-ui/src/main/java/de/symeda/sormas/ui/externalmessage/surveyresponse/SurveyResponseDetailsWindow.java b/sormas-ui/src/main/java/de/symeda/sormas/ui/externalmessage/surveyresponse/SurveyResponseDetailsWindow.java new file mode 100644 index 00000000000..234d22e21f5 --- /dev/null +++ b/sormas-ui/src/main/java/de/symeda/sormas/ui/externalmessage/surveyresponse/SurveyResponseDetailsWindow.java @@ -0,0 +1,253 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2026 SORMAS Foundation gGmbH + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU 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 General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.symeda.sormas.ui.externalmessage.surveyresponse; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import com.vaadin.server.ExternalResource; +import com.vaadin.server.Sizeable; +import com.vaadin.ui.*; +import com.vaadin.ui.themes.ValoTheme; + +import de.symeda.sormas.api.DiseaseHelper; +import de.symeda.sormas.api.FacadeProvider; +import de.symeda.sormas.api.externalmessage.ExternalMessageDto; +import de.symeda.sormas.api.externalmessage.ExternalMessageStatus; +import de.symeda.sormas.api.externalmessage.survey.ExternalMessageSurveyResponseRequest; +import de.symeda.sormas.api.externalmessage.survey.ExternalMessageSurveyResponseResult; +import de.symeda.sormas.api.externalmessage.survey.ExternalMessageSurveyResponseWrapper; +import de.symeda.sormas.api.i18n.Captions; +import de.symeda.sormas.api.i18n.I18nProperties; +import de.symeda.sormas.api.i18n.Strings; +import de.symeda.sormas.api.patch.DataPatchResponse; +import de.symeda.sormas.api.patch.partial_retrieval.DisplayableFieldInfo; +import de.symeda.sormas.api.patch.partial_retrieval.DisplayablePartialRetrievalResponse; +import de.symeda.sormas.api.user.UserRight; +import de.symeda.sormas.ui.UiUtil; +import de.symeda.sormas.ui.caze.CaseDataView; +import de.symeda.sormas.ui.utils.ButtonHelper; +import de.symeda.sormas.ui.utils.CssStyles; + +/** + * Popup window displaying full details of a SURVEY_RESPONSE external message. + */ +public class SurveyResponseDetailsWindow { + + public SurveyResponseDetailsWindow(ExternalMessageDto externalMessage, Runnable onFormActionPerformed) { + String uuid = externalMessage.getUuid(); + + DisplayablePartialRetrievalResponse displayData; + displayData = FacadeProvider.getExternalMessageFacade().fetchSurveyResponseFieldsForDisplay(uuid); + + VerticalLayout layout = new VerticalLayout(); + layout.setMargin(true); + layout.setSpacing(true); + layout.setWidth(100, Sizeable.Unit.PERCENTAGE); + + Window window = new Window(I18nProperties.getString(Strings.headingSurveyResponseDetails)); + window.setModal(true); + window.setResizable(true); + window.setWidth(850, Sizeable.Unit.PIXELS); + + ExternalMessageSurveyResponseWrapper latest = externalMessage.getSurveyResponseData().getLatest(); + ExternalMessageSurveyResponseRequest request = latest.getRequest(); + ExternalMessageSurveyResponseResult result = latest.getResult(); + + // --- External Message General Info section --- + Label generalInfoHeading = new Label(I18nProperties.getCaption(Captions.surveyResponseGeneralInfo)); + CssStyles.style(generalInfoHeading, CssStyles.H3); + layout.addComponent(generalInfoHeading); + + addReadOnlyField(layout, I18nProperties.getCaption(Captions.surveyResponseUuid), externalMessage.getUuid()); + addReadOnlyField( + layout, + I18nProperties.getPrefixCaption(ExternalMessageDto.I18N_PREFIX, ExternalMessageDto.DISEASE), + DiseaseHelper.toString(externalMessage.getDisease(), null)); + ExternalMessageStatus status = externalMessage.getStatus(); + addReadOnlyField( + layout, + I18nProperties.getPrefixCaption(ExternalMessageDto.I18N_PREFIX, ExternalMessageDto.STATUS), + status != null ? I18nProperties.getEnumCaption(status) : ""); + + // --- Metadata section --- + Label metadataHeading = new Label(I18nProperties.getCaption(Captions.surveyResponseMetadata)); + CssStyles.style(metadataHeading, CssStyles.H3); + layout.addComponent(metadataHeading); + + addReadOnlyField(layout, I18nProperties.getCaption(Captions.surveyResponseExternalSurveyId), request.getExternalSurveyId()); + addReadOnlyField(layout, I18nProperties.getCaption(Captions.surveyResponseToken), request.getToken()); + addReadOnlyField(layout, I18nProperties.getCaption(Captions.surveyResponseRespondentId), request.getExternalRespondentId()); + addReadOnlyField(layout, I18nProperties.getCaption(Captions.surveyResponseResponseReceivedDate), toStringOrEmpty(request.getResponseReceivedDate())); + addReadOnlyField(layout, I18nProperties.getCaption(Captions.surveyResponseReplacementStrategy), toStringOrEmpty(request.getReplacementStrategy())); + addReadOnlyField(layout, I18nProperties.getCaption(Captions.surveyResponseEmptyValueBehavior), toStringOrEmpty(request.getEmptyValueBehavior())); + addReadOnlyField(layout, I18nProperties.getCaption(Captions.surveyResponsePatchedInCaseOfFailures), String.valueOf(request.isPatchedInCaseOfFailures())); + + // --- Patch Dictionary section --- + Label dictionaryHeading = new Label(I18nProperties.getCaption(Captions.surveyResponsePatchDictionary)); + CssStyles.style(dictionaryHeading, CssStyles.H3); + layout.addComponent(dictionaryHeading); + + Map patchDictionary = request.getPatchDictionary(); + if (patchDictionary != null && !patchDictionary.isEmpty()) { + List> entries = patchDictionary.entrySet().stream().collect(Collectors.toList()); + + final DisplayablePartialRetrievalResponse finalDisplayData = displayData; + + Grid> dictionaryGrid = new Grid<>(); + dictionaryGrid.setSizeFull(); + dictionaryGrid.setItems(entries); + dictionaryGrid.setHeightByRows(Math.max(entries.size(), 1)); + + dictionaryGrid.addColumn(entry -> resolveFieldName(entry.getKey(), finalDisplayData)) + .setCaption(I18nProperties.getCaption(Captions.surveyResponseField)) + .setExpandRatio(2); + + dictionaryGrid.addColumn(entry -> entry.getValue() != null ? entry.getValue().toString() : "") + .setCaption(I18nProperties.getCaption(Captions.surveyResponseSubmittedValue)) + .setExpandRatio(2); + + dictionaryGrid.addColumn(entry -> resolveCurrentValue(entry.getKey(), finalDisplayData)) + .setCaption(I18nProperties.getCaption(Captions.surveyResponseCurrentCaseValue)) + .setExpandRatio(2); + + layout.addComponent(dictionaryGrid); + } + + // --- Ignored patch dictionary Patch Dictionary section --- + Label excludedFieldsDictionaryLabel = new Label(I18nProperties.getCaption(Captions.surveyResponseExcludedFieldsDictionary)); + CssStyles.style(excludedFieldsDictionaryLabel, CssStyles.H3); + layout.addComponent(excludedFieldsDictionaryLabel); + + Map excludedFieldsDictionary = request.getExcludedPatchDictionary(); + if (excludedFieldsDictionary != null && !excludedFieldsDictionary.isEmpty()) { + List> entries = excludedFieldsDictionary.entrySet().stream().collect(Collectors.toList()); + + final DisplayablePartialRetrievalResponse finalDisplayData = displayData; + + Grid> dictionaryGrid = new Grid<>(); + dictionaryGrid.setSizeFull(); + dictionaryGrid.setItems(entries); + dictionaryGrid.setHeightByRows(Math.max(entries.size(), 1)); + + dictionaryGrid.addColumn(entry -> resolveFieldName(entry.getKey(), finalDisplayData)) + .setCaption(I18nProperties.getCaption(Captions.surveyResponseField)) + .setExpandRatio(2); + + dictionaryGrid.addColumn(entry -> entry.getValue() != null ? entry.getValue().toString() : "") + .setCaption(I18nProperties.getCaption(Captions.surveyResponseSubmittedValue)) + .setExpandRatio(2); + + layout.addComponent(dictionaryGrid); + } + + // --- Processing Result section --- + if (result != null) { + Label resultHeading = new Label(I18nProperties.getCaption(Captions.surveyResponseProcessingResult)); + CssStyles.style(resultHeading, CssStyles.H3); + layout.addComponent(resultHeading); + + if (result.getCaseUuid() != null) { + Link caseLink = new Link( + I18nProperties.getCaption(Captions.surveyResponseCaseLink) + ": " + result.getCaseUuid(), + new ExternalResource("#!" + CaseDataView.VIEW_NAME + "/" + result.getCaseUuid())); + caseLink.setTargetName("_blank"); + layout.addComponent(caseLink); + } + + DataPatchResponse patchResponse = result.getPatchResponse(); + if (patchResponse != null) { + addReadOnlyField( + layout, + I18nProperties.getCaption(Captions.surveyResponseApplied), + patchResponse.isApplied() ? I18nProperties.getCaption(Captions.actionYes) : I18nProperties.getCaption(Captions.actionNo)); + + if (patchResponse.hasFailures()) { + Label failuresHeading = new Label(I18nProperties.getString(Strings.headingSurveyResponseFailures)); + CssStyles.style(failuresHeading, CssStyles.H4); + layout.addComponent(failuresHeading); + + layout.addComponent(new SurveyResponseFailurePanel(patchResponse.getFailures(), displayData)); + } else { + Label successLabel = new Label(I18nProperties.getString(Strings.messageSurveyResponseAllFieldsApplied)); + CssStyles.style(successLabel, ValoTheme.LABEL_SUCCESS); + layout.addComponent(successLabel); + } + } + } else { + Label notProcessedLabel = new Label(I18nProperties.getString(Strings.messageSurveyResponseNotYetProcessed)); + layout.addComponent(notProcessedLabel); + } + + // --- Actions bar (if processable and has failures) --- + boolean canProcess = UiUtil.permitted(UserRight.EXTERNAL_MESSAGE_SURVEY_RESPONSE_PROCESS); + boolean hasFailures = result != null + && result.getPatchResponse() != null + && result.getPatchResponse().hasFailures() + && !result.getPatchResponse().getFailures().isEmpty(); + + if (canProcess && hasFailures) { + final DisplayablePartialRetrievalResponse finalDisplayDataForEditor = displayData; + Button correctButton = + ButtonHelper.createButton(Captions.actionCorrectAndReprocess, I18nProperties.getCaption(Captions.actionCorrectAndReprocess), e -> { + ExternalMessageDto refreshedDto = FacadeProvider.getExternalMessageFacade().getByUuid(uuid); + SurveyResponseFailureEditor editor = new SurveyResponseFailureEditor(refreshedDto, finalDisplayDataForEditor, () -> { + window.close(); + onFormActionPerformed.run(); + }); + UI.getCurrent().addWindow(editor); + }, ValoTheme.BUTTON_PRIMARY); + + layout.addComponent(correctButton); + } + + window.setContent(layout); + UI.getCurrent().addWindow(window); + } + + private static String toStringOrEmpty(Object value) { + return value != null ? value.toString() : ""; + } + + private void addReadOnlyField(VerticalLayout layout, String caption, String value) { + Label label = new Label(value != null ? value : ""); + label.setCaption(caption); + layout.addComponent(label); + } + + public String resolveFieldName(String fieldPath, DisplayablePartialRetrievalResponse displayData) { + DisplayableFieldInfo info = displayData.getFieldInfoDictionary().get(fieldPath); + String aliasPath = FacadeProvider.getPathAliasFacade().fetchAliasPath(fieldPath); + if (info != null) { + String translatedFieldName = info.getTranslatedFieldName(); + if (translatedFieldName != null) { + return String.format("%s (%s)", translatedFieldName, aliasPath); + } + } + return aliasPath; + } + + private String resolveCurrentValue(String fieldPath, DisplayablePartialRetrievalResponse displayData) { + DisplayableFieldInfo info = displayData.getFieldInfoDictionary().get(fieldPath); + if (info != null) { + String translatedFieldValue = info.getTranslatedFieldValue(); + if (translatedFieldValue != null) { + return translatedFieldValue; + } + } + return ""; + } +} diff --git a/sormas-ui/src/main/java/de/symeda/sormas/ui/externalmessage/surveyresponse/SurveyResponseFailureEditor.java b/sormas-ui/src/main/java/de/symeda/sormas/ui/externalmessage/surveyresponse/SurveyResponseFailureEditor.java new file mode 100644 index 00000000000..3a0f4ff9e14 --- /dev/null +++ b/sormas-ui/src/main/java/de/symeda/sormas/ui/externalmessage/surveyresponse/SurveyResponseFailureEditor.java @@ -0,0 +1,215 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2026 SORMAS Foundation gGmbH + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU 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 General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.symeda.sormas.ui.externalmessage.surveyresponse; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import com.vaadin.ui.*; +import com.vaadin.ui.themes.ValoTheme; + +import de.symeda.sormas.api.FacadeProvider; +import de.symeda.sormas.api.externalmessage.ExternalMessageDto; +import de.symeda.sormas.api.externalmessage.survey.ExternalMessageSurveyResponseResult; +import de.symeda.sormas.api.externalmessage.survey.ExternalMessageSurveyResponseWrapper; +import de.symeda.sormas.api.i18n.Captions; +import de.symeda.sormas.api.i18n.I18nProperties; +import de.symeda.sormas.api.i18n.Strings; +import de.symeda.sormas.api.patch.DataPatchFailure; +import de.symeda.sormas.api.patch.partial_retrieval.DisplayableFieldInfo; +import de.symeda.sormas.api.patch.partial_retrieval.DisplayablePartialRetrievalResponse; +import de.symeda.sormas.ui.utils.ButtonHelper; +import de.symeda.sormas.ui.utils.CssStyles; + +/** + * Modal editor window allowing users to correct failed survey response fields and reprocess. + * Each failed field can be ignored (excluded from reprocessing) or have its key renamed. + */ +public class SurveyResponseFailureEditor extends Window { + + private static final long serialVersionUID = 4912870523418167234L; + + public SurveyResponseFailureEditor(ExternalMessageDto externalMessage, DisplayablePartialRetrievalResponse displayData, Runnable onReprocessed) { + setCaption(I18nProperties.getString(Strings.headingSurveyResponseCorrectAndReprocess)); + setModal(true); + setResizable(true); + setWidth(700, Unit.PIXELS); + + ExternalMessageSurveyResponseWrapper latest = externalMessage.getSurveyResponseData().getLatest(); + + ExternalMessageSurveyResponseResult result = latest.getResult(); + Map failures = + result != null && result.getPatchResponse() != null ? result.getPatchResponse().getFailures() : new HashMap<>(); + Map validValues = + result != null && result.getPatchResponse() != null ? result.getPatchResponse().getValidPatchDictionary() : new HashMap<>(); + + VerticalLayout mainLayout = new VerticalLayout(); + mainLayout.setMargin(true); + mainLayout.setSpacing(true); + + // --- Failed fields (editable) --- + if (!failures.isEmpty()) { + Label failuresHeading = new Label(I18nProperties.getString(Strings.headingSurveyResponseFailures)); + CssStyles.style(failuresHeading, CssStyles.H3); + mainLayout.addComponent(failuresHeading); + + FormLayout failuresForm = new FormLayout(); + failuresForm.setMargin(false); + + Map ignoreCheckboxes = new HashMap<>(); + Map keyEditors = new HashMap<>(); + Map valueEditors = new HashMap<>(); + + for (Map.Entry entry : failures.entrySet()) { + String fieldPath = entry.getKey(); + DataPatchFailure failure = entry.getValue(); + + String fieldLabel = resolveFieldName(fieldPath, displayData); + String currentValue = resolveCurrentValue(fieldPath, displayData); + String causeName = + failure.getDataPatchFailureCause() != null ? I18nProperties.getEnumCaption(failure.getDataPatchFailureCause()) : ""; + + VerticalLayout fieldContainer = new VerticalLayout(); + fieldContainer.setMargin(false); + fieldContainer.setSpacing(false); + + // Ignore checkbox + CheckBox ignoreCheckbox = new CheckBox(I18nProperties.getCaption(Captions.surveyResponseIgnoreField)); + ignoreCheckboxes.put(fieldPath, ignoreCheckbox); + fieldContainer.addComponent(ignoreCheckbox); + + Label causeLabel = new Label(I18nProperties.getCaption(Captions.surveyResponseFailureCause) + ": " + causeName); + CssStyles.style(causeLabel, CssStyles.LABEL_SMALL, CssStyles.LABEL_SECONDARY); + fieldContainer.addComponent(causeLabel); + + Label currentValueLabel = + new Label(I18nProperties.getCaption(Captions.surveyResponseCurrentCaseValue) + ": " + (currentValue != null ? currentValue : "")); + CssStyles.style(currentValueLabel, CssStyles.LABEL_SMALL, CssStyles.LABEL_SECONDARY); + fieldContainer.addComponent(currentValueLabel); + + // Key rename field + TextField keyField = new TextField(I18nProperties.getCaption(Captions.surveyResponseKeyName)); + keyField.setValue(fieldPath); + keyField.setWidth(100, Unit.PERCENTAGE); + keyEditors.put(fieldPath, keyField); + fieldContainer.addComponent(keyField); + + // Value field + TextField valueField = new TextField(fieldLabel); + valueField.setWidth(100, Unit.PERCENTAGE); + if (failure.getProvidedFieldValue() != null) { + valueField.setValue(failure.getProvidedFieldValue().toString()); + } + valueEditors.put(fieldPath, valueField); + fieldContainer.addComponent(valueField); + + // Wire ignore checkbox to disable key/value fields + ignoreCheckbox.addValueChangeListener(event -> { + boolean ignored = Boolean.TRUE.equals(event.getValue()); + keyField.setEnabled(!ignored); + valueField.setEnabled(!ignored); + }); + + failuresForm.addComponent(fieldContainer); + } + + mainLayout.addComponent(failuresForm); + + // --- Valid fields (read-only context) ---§ + if (!validValues.isEmpty()) { + Label validHeading = new Label(I18nProperties.getCaption(Captions.surveyResponseValidFields)); + CssStyles.style(validHeading, CssStyles.H4); + mainLayout.addComponent(validHeading); + + List> validEntries = validValues.entrySet().stream().collect(Collectors.toList()); + + Grid> validGrid = new Grid<>(); + validGrid.setSizeFull(); + validGrid.setItems(validEntries); + validGrid.setHeightByRows(Math.max(validEntries.size(), 1)); + + validGrid.addColumn(entry -> resolveFieldName(entry.getKey(), displayData)) + .setCaption(I18nProperties.getCaption(Captions.surveyResponseField)) + .setExpandRatio(2); + + validGrid.addColumn(entry -> entry.getValue() != null ? entry.getValue().toString() : "") + .setCaption(I18nProperties.getCaption(Captions.surveyResponseSubmittedValue)) + .setExpandRatio(2); + + validGrid.addColumn(entry -> resolveCurrentValue(entry.getKey(), displayData)) + .setCaption(I18nProperties.getCaption(Captions.surveyResponseCurrentCaseValue)) + .setExpandRatio(2); + + mainLayout.addComponent(validGrid); + } + + // --- Buttons --- + HorizontalLayout buttonsLayout = new HorizontalLayout(); + buttonsLayout.setSpacing(true); + + Button saveAndReprocessButton = + ButtonHelper.createButton(Captions.actionSaveAndReprocess, I18nProperties.getCaption(Captions.actionSaveAndReprocess), e -> { + Map correctedDictionary = new HashMap<>(validValues); + for (String fieldPath : valueEditors.keySet()) { + CheckBox ignoreCheckbox = ignoreCheckboxes.get(fieldPath); + if (ignoreCheckbox != null && Boolean.TRUE.equals(ignoreCheckbox.getValue())) { + continue; + } + String key = keyEditors.get(fieldPath).getValue(); + if (key == null || key.trim().isEmpty()) { + key = fieldPath; + } + correctedDictionary.put(key, valueEditors.get(fieldPath).getValue()); + } + + FacadeProvider.getExternalMessageFacade().overwriteSurveyResponse(externalMessage.getUuid(), correctedDictionary); + + Notification.show(I18nProperties.getString(Strings.messageSurveyResponseReprocessed), Notification.Type.HUMANIZED_MESSAGE); + close(); + onReprocessed.run(); + }, ValoTheme.BUTTON_PRIMARY); + + Button cancelButton = ButtonHelper.createButton(Captions.actionCancel, I18nProperties.getCaption(Captions.actionCancel), e -> close()); + + buttonsLayout.addComponent(saveAndReprocessButton); + buttonsLayout.addComponent(cancelButton); + mainLayout.addComponent(buttonsLayout); + } + + setContent(mainLayout); + } + + public String resolveFieldName(String fieldPath, DisplayablePartialRetrievalResponse displayData) { + DisplayableFieldInfo info = displayData.getFieldInfoDictionary().get(fieldPath); + String aliasPath = FacadeProvider.getPathAliasFacade().fetchAliasPath(fieldPath); + if (info != null) { + String translatedFieldName = info.getTranslatedFieldName(); + if (translatedFieldName != null) { + return String.format("%s (%s)", translatedFieldName, aliasPath); + } + } + return aliasPath; + } + + private String resolveCurrentValue(String fieldPath, DisplayablePartialRetrievalResponse displayData) { + DisplayableFieldInfo info = displayData.getFieldInfoDictionary().get(fieldPath); + if (info != null) { + return info.getTranslatedFieldValue(); + } + return null; + } +} diff --git a/sormas-ui/src/main/java/de/symeda/sormas/ui/externalmessage/surveyresponse/SurveyResponseFailurePanel.java b/sormas-ui/src/main/java/de/symeda/sormas/ui/externalmessage/surveyresponse/SurveyResponseFailurePanel.java new file mode 100644 index 00000000000..abe6a8721d1 --- /dev/null +++ b/sormas-ui/src/main/java/de/symeda/sormas/ui/externalmessage/surveyresponse/SurveyResponseFailurePanel.java @@ -0,0 +1,97 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2026 SORMAS Foundation gGmbH + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU 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 General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.symeda.sormas.ui.externalmessage.surveyresponse; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import com.vaadin.ui.Grid; +import com.vaadin.ui.VerticalLayout; + +import de.symeda.sormas.api.FacadeProvider; +import de.symeda.sormas.api.i18n.Captions; +import de.symeda.sormas.api.i18n.I18nProperties; +import de.symeda.sormas.api.patch.DataPatchFailure; +import de.symeda.sormas.api.patch.partial_retrieval.DisplayableFieldInfo; +import de.symeda.sormas.api.patch.partial_retrieval.DisplayablePartialRetrievalResponse; + +/** + * Read-only panel displaying patch failures for a survey response. + * Shows all failures including non-correctable ones so the user can understand what happened. + */ +public class SurveyResponseFailurePanel extends VerticalLayout { + + private static final long serialVersionUID = -2309124756823178543L; + + public SurveyResponseFailurePanel(Map failures, DisplayablePartialRetrievalResponse displayData) { + setMargin(false); + setSpacing(true); + setSizeFull(); + + List> failureEntries = failures.entrySet().stream().collect(Collectors.toList()); + + Grid> grid = new Grid<>(); + grid.setSizeFull(); + grid.setItems(failureEntries); + + grid.addColumn(entry -> resolveFieldName(entry.getKey(), displayData)) + .setCaption(I18nProperties.getCaption(Captions.surveyResponseField)) + .setId("field") + .setExpandRatio(2); + + grid.addColumn( + entry -> entry.getValue().getDataPatchFailureCause() != null + ? I18nProperties.getEnumCaption(entry.getValue().getDataPatchFailureCause()) + : "") + .setCaption(I18nProperties.getCaption(Captions.surveyResponseFailureCause)) + .setId("cause") + .setExpandRatio(2); + + grid.addColumn(entry -> entry.getValue().getProvidedFieldValue() != null ? entry.getValue().getProvidedFieldValue().toString() : "") + .setCaption(I18nProperties.getCaption(Captions.surveyResponseSubmittedValue)) + .setId("submittedValue") + .setExpandRatio(2); + + grid.addColumn(entry -> resolveCurrentValue(entry.getKey(), displayData)) + .setCaption(I18nProperties.getCaption(Captions.surveyResponseCurrentCaseValue)) + .setId("currentValue") + .setExpandRatio(2); + + grid.setHeightByRows(Math.max(failureEntries.size(), 1)); + + addComponent(grid); + } + + public String resolveFieldName(String fieldPath, DisplayablePartialRetrievalResponse displayData) { + DisplayableFieldInfo info = displayData.getFieldInfoDictionary().get(fieldPath); + String aliasPath = FacadeProvider.getPathAliasFacade().fetchAliasPath(fieldPath); + if (info != null) { + String translatedFieldName = info.getTranslatedFieldName(); + if (translatedFieldName != null) { + return String.format("%s (%s)", translatedFieldName, aliasPath); + } + } + return aliasPath; + } + + private String resolveCurrentValue(String fieldPath, DisplayablePartialRetrievalResponse displayData) { + DisplayableFieldInfo info = displayData.getFieldInfoDictionary().get(fieldPath); + if (info != null && info.getTranslatedFieldValue() != null) { + return info.getTranslatedFieldValue(); + } + return ""; + } +} diff --git a/sormas-ui/src/main/java/de/symeda/sormas/ui/survey/SurveyDataForm.java b/sormas-ui/src/main/java/de/symeda/sormas/ui/survey/SurveyDataForm.java index f0a3cfb5f09..0941333812f 100644 --- a/sormas-ui/src/main/java/de/symeda/sormas/ui/survey/SurveyDataForm.java +++ b/sormas-ui/src/main/java/de/symeda/sormas/ui/survey/SurveyDataForm.java @@ -20,6 +20,7 @@ public class SurveyDataForm extends AbstractEditForm { //@formatter:off private static final String HTML_LAYOUT = fluidRowLocs(SurveyDto.SURVEY_NAME, "") + fluidRowLocs(SurveyDto.DISEASE, "") + + fluidRowLocs(SurveyDto.EXTERNAL_ID, "") + fluidRowLocs(SURVEY_DOCUMENT_SECTION, ""); //@formatter:on @@ -43,21 +44,21 @@ protected void addFields() { addField(SurveyDto.SURVEY_NAME, TextField.class); + addField(SurveyDto.EXTERNAL_ID, TextField.class); + DocumentTemplateSection documentTemplateSection = new DocumentTemplateSection( - new DocumentTemplateCriteria(DocumentWorkflow.SURVEY_DOCUMENT, null, surveyReference), - false, - new SurveyDocumentTemplateReceiver(DocumentWorkflow.SURVEY_DOCUMENT, surveyReference)); + new DocumentTemplateCriteria(DocumentWorkflow.SURVEY_DOCUMENT, null, surveyReference), + false, + new SurveyDocumentTemplateReceiver(DocumentWorkflow.SURVEY_DOCUMENT, surveyReference)); DocumentTemplateSection emailTemplateSection = new DocumentTemplateSection( - new DocumentTemplateCriteria(DocumentWorkflow.SURVEY_EMAIL, null, surveyReference), - false, - new SurveyEmailTemplateReceiver(DocumentWorkflow.SURVEY_EMAIL, surveyReference)); + new DocumentTemplateCriteria(DocumentWorkflow.SURVEY_EMAIL, null, surveyReference), + false, + new SurveyEmailTemplateReceiver(DocumentWorkflow.SURVEY_EMAIL, surveyReference)); documentTemplateSection.setMargin(false); emailTemplateSection.setMargin(false); - gridLayout = new VerticalLayout( - documentTemplateSection, - emailTemplateSection); + gridLayout = new VerticalLayout(documentTemplateSection, emailTemplateSection); gridLayout.setWidth(100, Unit.PERCENTAGE); gridLayout.setMargin(new MarginInfo(true, false, true, false)); diff --git a/sormas-ui/src/main/java/de/symeda/sormas/ui/survey/SurveyListComponentLayout.java b/sormas-ui/src/main/java/de/symeda/sormas/ui/survey/SurveyListComponentLayout.java index 872c875d28e..c55b8da13bb 100644 --- a/sormas-ui/src/main/java/de/symeda/sormas/ui/survey/SurveyListComponentLayout.java +++ b/sormas-ui/src/main/java/de/symeda/sormas/ui/survey/SurveyListComponentLayout.java @@ -179,6 +179,15 @@ protected void drawDisplayedEntries() { listEntry.setComponentAlignment(downloadButton, Alignment.TOP_RIGHT); listEntry.setExpandRatio(downloadButton, 0); + if (Boolean.TRUE.equals(token.getResponseReceived()) && StringUtils.isNotBlank(token.getExternalRespondentId())) { + Button eyeButton = ButtonHelper.createIconButton(null, VaadinIcons.EYE, e -> { + new SurveyQuestionnaireWindow(listEntry.getToken().toReference()); + }, ValoTheme.BUTTON_LINK, CssStyles.BUTTON_COMPACT); + listEntry.addComponent(eyeButton); + listEntry.setComponentAlignment(eyeButton, Alignment.TOP_RIGHT); + listEntry.setExpandRatio(eyeButton, 0); + } + listEntry.addActionButton(String.valueOf(i), (Button.ClickListener) clickEvent -> { ControllerProvider.getSurveyTokenController().showCaseSurveyDetails(listEntry.getToken().toReference(), this::reload); }, UiUtil.permitted(isEditAllowed, UserRight.SURVEY_EDIT)); diff --git a/sormas-ui/src/main/java/de/symeda/sormas/ui/survey/SurveyQuestionnaireWindow.java b/sormas-ui/src/main/java/de/symeda/sormas/ui/survey/SurveyQuestionnaireWindow.java new file mode 100644 index 00000000000..8e4f8f04aa4 --- /dev/null +++ b/sormas-ui/src/main/java/de/symeda/sormas/ui/survey/SurveyQuestionnaireWindow.java @@ -0,0 +1,130 @@ +/* + * SORMAS® - Surveillance Outbreak Response Management & Analysis System + * Copyright © 2016-2026 SORMAS Foundation gGmbH + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU 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 General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.symeda.sormas.ui.survey; + +import java.util.List; + +import com.vaadin.shared.ui.ContentMode; +import com.vaadin.ui.Label; +import com.vaadin.ui.Panel; +import com.vaadin.ui.UI; +import com.vaadin.ui.VerticalLayout; +import com.vaadin.ui.Window; + +import de.symeda.sormas.api.FacadeProvider; +import de.symeda.sormas.api.survey.SurveyTokenReferenceDto; +import de.symeda.sormas.api.survey.external.views.ExternalSurveyView; +import de.symeda.sormas.api.survey.external.views.QuestionAnswersView; +import org.apache.commons.lang3.StringUtils; + +/** + * Modal window displaying a survey questionnaire as a structured HTML view. + * Shows questions, their answers and nested subquestions. + * + * @implNote this was inspired from a survey tool's simplified view within EMAL. + */ +public class SurveyQuestionnaireWindow { + + public SurveyQuestionnaireWindow(SurveyTokenReferenceDto surveyTokenRef) { + ExternalSurveyView surveyView; + try { + surveyView = FacadeProvider.getSurveyTokenFacade().getExternalSurveyView(surveyTokenRef.getUuid()); + } catch (Exception e) { + surveyView = null; + } + + Window window = new Window(surveyTokenRef.getCaption()); + window.setModal(true); + window.setResizable(true); + window.setWidth(700, com.vaadin.server.Sizeable.Unit.PIXELS); + window.setHeight(80, com.vaadin.server.Sizeable.Unit.PERCENTAGE); + + VerticalLayout mainLayout = new VerticalLayout(); + mainLayout.setMargin(true); + mainLayout.setSpacing(false); + + if (surveyView == null || surveyView.getQuestionAnswersViews() == null || surveyView.getQuestionAnswersViews().isEmpty()) { + mainLayout.addComponent(new Label("No questionnaire data available.")); + } else { + Label htmlContent = new Label(buildHtml(surveyView.getQuestionAnswersViews()), ContentMode.HTML); + htmlContent.setSizeFull(); + mainLayout.addComponent(htmlContent); + } + + Panel scrollPanel = new Panel(mainLayout); + scrollPanel.setSizeFull(); + + window.setContent(scrollPanel); + UI.getCurrent().addWindow(window); + } + + private String buildHtml(List questions) { + StringBuilder sb = new StringBuilder(); + sb.append(""); + sb.append("") + .append("") + .append("") + .append(""); + sb.append(""); + + for (QuestionAnswersView q : questions) { + appendQuestion(sb, q, 0); + } + + sb.append("
QuestionAnswer
"); + return sb.toString(); + } + + private void appendQuestion(StringBuilder sb, QuestionAnswersView q, int depth) { + boolean hasSubquestions = q.getSubquestions() != null && !q.getSubquestions().isEmpty(); + String indent = depth > 0 ? "padding-left:" + (depth * 20) + "px;" : ""; + String rowStyle = depth > 0 ? "background:#fafafa;" : "background:#fff;"; + String questionStyle = hasSubquestions && q.getAnswer() == null ? "font-weight:bold;color:#333;" : "color:#333;"; + + sb.append("") + .append("") + .append(q.getQuestion()) + .append("") + .append("") + .append(resolveAnswer(q)) + .append("") + .append(""); + + if (hasSubquestions) { + for (QuestionAnswersView sub : q.getSubquestions()) { + appendQuestion(sb, sub, depth + 1); + } + } + } + + private String resolveAnswer(QuestionAnswersView q) { + // Prefer human-readable answerText, fall back to raw answer + String answerText = q.getAnswerText(); + if (StringUtils.isNotBlank(answerText)) { + return answerText; + } + String answer = q.getAnswer(); + if (StringUtils.isNotBlank(answer)) { + return answer; + } + return ""; + } + +} diff --git a/sormas-ui/src/main/java/de/symeda/sormas/ui/survey/SurveyTokenDataForm.java b/sormas-ui/src/main/java/de/symeda/sormas/ui/survey/SurveyTokenDataForm.java index 9b386ac077c..98c1397e849 100644 --- a/sormas-ui/src/main/java/de/symeda/sormas/ui/survey/SurveyTokenDataForm.java +++ b/sormas-ui/src/main/java/de/symeda/sormas/ui/survey/SurveyTokenDataForm.java @@ -28,7 +28,8 @@ public class SurveyTokenDataForm extends AbstractEditForm { private static final String HTML_LAYOUT = fluidRowLocs(SurveyTokenDto.UUID, SurveyTokenDto.TOKEN) + fluidRowLocs(SurveyTokenDto.SURVEY, "") + fluidRowLocs(SurveyTokenDto.ASSIGNMENT_DATE, SurveyTokenDto.RECIPIENT_EMAIL) - + fluidRowLocs(SurveyTokenDto.RESPONSE_RECEIVED, SurveyTokenDto.RESPONSE_RECEIVED_DATE); + + fluidRowLocs(SurveyTokenDto.RESPONSE_RECEIVED, SurveyTokenDto.RESPONSE_RECEIVED_DATE) + + fluidRowLocs(SurveyTokenDto.EXTERNAL_RESPONDENT_ID); private SurveyTokenReferenceDto surveyTokenReference; @@ -55,6 +56,7 @@ protected void addFields() { addField(SurveyTokenDto.RECIPIENT_EMAIL).setReadOnly(true); addField(SurveyTokenDto.RESPONSE_RECEIVED).addStyleName(CssStyles.FORCE_CAPTION); addField(SurveyTokenDto.RESPONSE_RECEIVED_DATE); + addField(SurveyTokenDto.EXTERNAL_RESPONDENT_ID).setReadOnly(true); FieldHelper.setVisibleWhen(getFieldGroup(), SurveyTokenDto.RESPONSE_RECEIVED_DATE, SurveyTokenDto.RESPONSE_RECEIVED, true, true); } diff --git a/sormas-ui/src/main/webapp/WEB-INF/glassfish-web.xml b/sormas-ui/src/main/webapp/WEB-INF/glassfish-web.xml index 9970219c1f3..f852e45244b 100644 --- a/sormas-ui/src/main/webapp/WEB-INF/glassfish-web.xml +++ b/sormas-ui/src/main/webapp/WEB-INF/glassfish-web.xml @@ -858,6 +858,11 @@ EXTERNAL_MESSAGE_DOCTOR_DECLARATION_VIEW
+ + EXTERNAL_MESSAGE_SURVEY_RESPONSE_VIEW + EXTERNAL_MESSAGE_SURVEY_RESPONSE_VIEW + + EXTERNAL_MESSAGE_LABORATORY_PROCESS EXTERNAL_MESSAGE_LABORATORY_PROCESS @@ -868,6 +873,12 @@ EXTERNAL_MESSAGE_DOCTOR_DECLARATION_PROCESS + + + EXTERNAL_MESSAGE_SURVEY_RESPONSE_PROCESS + EXTERNAL_MESSAGE_SURVEY_RESPONSE_PROCESS + + EXTERNAL_MESSAGE_LABORATORY_DELETE EXTERNAL_MESSAGE_LABORATORY_DELETE @@ -878,6 +889,11 @@ EXTERNAL_MESSAGE_DOCTOR_DECLARATION_DELETE + + EXTERNAL_MESSAGE_SURVEY_RESPONSE_DELETE + EXTERNAL_MESSAGE_SURVEY_RESPONSE_DELETE + + TRAVEL_ENTRY_MANAGEMENT_ACCESS TRAVEL_ENTRY_MANAGEMENT_ACCESS diff --git a/sormas-ui/src/main/webapp/WEB-INF/web.xml b/sormas-ui/src/main/webapp/WEB-INF/web.xml index a9f10d30120..0e528ae98e9 100644 --- a/sormas-ui/src/main/webapp/WEB-INF/web.xml +++ b/sormas-ui/src/main/webapp/WEB-INF/web.xml @@ -701,6 +701,10 @@ EXTERNAL_MESSAGE_DOCTOR_DECLARATION_VIEW + + EXTERNAL_MESSAGE_SURVEY_RESPONSE_VIEW + + EXTERNAL_MESSAGE_LABORATORY_PROCESS @@ -709,6 +713,10 @@ EXTERNAL_MESSAGE_DOCTOR_DECLARATION_PROCESS + + EXTERNAL_MESSAGE_SURVEY_RESPONSE_PROCESS + + EXTERNAL_MESSAGE_LABORATORY_DELETE @@ -717,6 +725,10 @@ EXTERNAL_MESSAGE_DOCTOR_DECLARATION_DELETE + + EXTERNAL_MESSAGE_SURVEY_RESPONSE_DELETE + + TRAVEL_ENTRY_MANAGEMENT_ACCESS