Skip to content

Commit 69d2532

Browse files
authored
Merge pull request #14127 from SORMAS-Foundation/feat/14123-allowed-equal-value-override
Allowing configurable value override when equal and DataReplacementStrategy.IF_NOT_ALREADY_PRESENT
2 parents b07aa91 + 6f85fec commit 69d2532

4 files changed

Lines changed: 308 additions & 14 deletions

File tree

sormas-backend/src/main/java/de/symeda/sormas/backend/patch/DataPatcherImpl.java

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
package de.symeda.sormas.backend.patch;
22

3-
import java.util.*;
3+
import java.util.AbstractMap;
4+
import java.util.HashMap;
5+
import java.util.LinkedHashMap;
6+
import java.util.List;
7+
import java.util.Map;
8+
import java.util.Objects;
9+
import java.util.Optional;
410
import java.util.function.Function;
511
import java.util.function.Predicate;
612
import java.util.function.Supplier;
@@ -22,7 +28,15 @@
2228
import de.symeda.sormas.api.customizablefield.CustomizableFieldValueDto;
2329
import de.symeda.sormas.api.externalmessage.survey.PatchDictionary;
2430
import de.symeda.sormas.api.externalmessage.survey.PatchField;
25-
import de.symeda.sormas.api.patch.*;
31+
import de.symeda.sormas.api.patch.CaseDataPatchRequest;
32+
import de.symeda.sormas.api.patch.DataPatchFailure;
33+
import de.symeda.sormas.api.patch.DataPatchFailureCause;
34+
import de.symeda.sormas.api.patch.DataPatchResponse;
35+
import de.symeda.sormas.api.patch.DataPatcher;
36+
import de.symeda.sormas.api.patch.DataReplacementStrategy;
37+
import de.symeda.sormas.api.patch.EmptyValueBehavior;
38+
import de.symeda.sormas.api.patch.PlainSinglePatchResult;
39+
import de.symeda.sormas.api.patch.SinglePatchResult;
2640
import de.symeda.sormas.api.patch.mapping.FieldCustomMapper;
2741
import de.symeda.sormas.api.patch.mapping.FieldPatchRequest;
2842
import de.symeda.sormas.api.patch.mapping.ValueMappingResult;
@@ -32,7 +46,11 @@
3246
import de.symeda.sormas.backend.common.ConfigFacadeEjb;
3347
import de.symeda.sormas.backend.feature.FeatureConfigurationFacadeEjb;
3448
import de.symeda.sormas.backend.json.ObjectMapperProvider;
35-
import de.symeda.sormas.backend.patch.customizablefield.*;
49+
import de.symeda.sormas.backend.patch.customizablefield.CustomizableContextIndexKey;
50+
import de.symeda.sormas.backend.patch.customizablefield.CustomizableFieldContextPatchMapping;
51+
import de.symeda.sormas.backend.patch.customizablefield.CustomizableFieldDataPatchRequest;
52+
import de.symeda.sormas.backend.patch.customizablefield.CustomizableFieldDataPatcher;
53+
import de.symeda.sormas.backend.patch.customizablefield.CustomizableFieldSinglePatchingResult;
3654
import de.symeda.sormas.backend.patch.mapping.FieldCustomMapperRegistry;
3755
import de.symeda.sormas.backend.patch.mapping.PatchEqualityCheckersRegistry;
3856
import de.symeda.sormas.backend.patch.mapping.ValueMapperRegistry;
@@ -67,6 +85,9 @@ public class DataPatcherImpl implements DataPatcher {
6785
@Inject
6886
private CustomizableFieldDataPatcher customizableFieldDataPatcher;
6987

88+
@Inject
89+
private EqualValueOverrideHelper equalValueOverrideHelper;
90+
7091
public DataPatcherImpl() {
7192
}
7293

@@ -273,7 +294,7 @@ private void saveDTOsIfAppropriate(Map<Tuple<String, Integer>, AttachedEntityWra
273294
return singlePatchResult.setFailure(buildFailure(dataPatchFailureCause, untypedTargetValue));
274295
}
275296

276-
Object typedValue = result.getData();
297+
Object newTypedValue = result.getData();
277298

278299
if (!attachedEntityWrapper.isAttached() && !StringUtils.contains(relativeFieldName, ".")) {
279300
logger.debug(
@@ -286,19 +307,25 @@ private void saveDTOsIfAppropriate(Map<Tuple<String, Integer>, AttachedEntityWra
286307
if (nestedPropertyValue.isPresent()) {
287308
Object currentValue = nestedPropertyValue.orElseThrow();
288309

289-
if (!patchEqualityCheckersRegistry.areEqual(currentValue, typedValue)) {
290-
return singlePatchResult.setFailure(
291-
new DataPatchFailure().setDataPatchFailureCause(DataPatchFailureCause.FORBIDDEN_VALUE_OVERRIDE)
292-
.setExistingFieldValue(currentValue)
293-
.setProvidedFieldValue(untypedTargetValue));
310+
if (!patchEqualityCheckersRegistry.areEqual(currentValue, newTypedValue)) {
311+
if (equalValueOverrideHelper.allowedOverride(currentValue)) {
312+
logger.info(
313+
"Current value to [{}] was different to newValue but override was allowed for this value even if config was: DataReplacementStrategy.IF_NOT_ALREADY_PRESENT",
314+
currentValue);
315+
} else {
316+
return singlePatchResult.setFailure(
317+
new DataPatchFailure().setDataPatchFailureCause(DataPatchFailureCause.FORBIDDEN_VALUE_OVERRIDE)
318+
.setExistingFieldValue(currentValue)
319+
.setProvidedFieldValue(untypedTargetValue));
320+
}
294321
}
295322
}
296323
}
297324

298-
Optional<Exception> exception = PropertyAccessor.setNestedProperty(target, relativeFieldName, typedValue);
325+
Optional<Exception> exception = PropertyAccessor.setNestedProperty(target, relativeFieldName, newTypedValue);
299326
if (exception.isPresent()) {
300327
Exception e = exception.orElseThrow();
301-
logger.error("Setting nested property failed for: field [{}] on [{}] with value: [{}]", relativeFieldName, target, typedValue, e);
328+
logger.error("Setting nested property failed for: field [{}] on [{}] with value: [{}]", relativeFieldName, target, newTypedValue, e);
302329
return singlePatchResult.setFailure(
303330
new DataPatchFailure().setDataPatchFailureCause(DataPatchFailureCause.TECHNICAL).setProvidedFieldValue(untypedTargetValue));
304331
} else {
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package de.symeda.sormas.backend.patch;
2+
3+
import java.util.Arrays;
4+
import java.util.Collections;
5+
import java.util.Optional;
6+
import java.util.Set;
7+
import java.util.stream.Collectors;
8+
9+
import javax.ejb.EJB;
10+
import javax.enterprise.context.ApplicationScoped;
11+
12+
import org.jetbrains.annotations.NotNull;
13+
14+
import de.symeda.sormas.api.systemconfiguration.SystemConfigurationValueFacade;
15+
import de.symeda.sormas.backend.util.StringNormalizer;
16+
17+
/**
18+
* Due to values being sometime set by external sources (Lab messages) in a non-optimal manner,
19+
* some fields have "fallback"-values that should be allowed to be overridden.
20+
*/
21+
@ApplicationScoped
22+
public class EqualValueOverrideHelper {
23+
24+
public static final String ALLOWED_EQUALITY_VALUE_OVERRIDE_KEY = "ALLOWED_EQUALITY_VALUE_OVERRIDE";
25+
26+
@EJB
27+
private SystemConfigurationValueFacade systemConfigurationValueFacade;
28+
29+
/**
30+
* If current value and new value to patch are different, (depending on config, see below) it must be checked if override is still
31+
* allowed.
32+
* {@link de.symeda.sormas.api.patch.DataReplacementStrategy#IF_NOT_ALREADY_PRESENT}
33+
*
34+
* @param value
35+
* **current** value that is different from the new value in the system.
36+
* @return true: means the value even can be overridden, false: cannot be overridden because not part of exceptions.
37+
*/
38+
public boolean allowedOverride(Object value) {
39+
String valueAsString = StringNormalizer.normalize(value.toString());
40+
41+
Set<String> allowedEqualityOverrides = resolveConfiguredForbiddenFields();
42+
43+
return allowedEqualityOverrides.contains(valueAsString)
44+
|| allowedEqualityOverrides.contains(getValueAsStringWithTypePrefix(value, valueAsString));
45+
}
46+
47+
private static @NotNull String getValueAsStringWithTypePrefix(Object value, String valueAsString) {
48+
return StringNormalizer.normalize(String.format("%s___%s", value.getClass().getSimpleName(), valueAsString));
49+
}
50+
51+
private Set<String> resolveConfiguredForbiddenFields() {
52+
String configValue = systemConfigurationValueFacade.getValue(ALLOWED_EQUALITY_VALUE_OVERRIDE_KEY);
53+
return Optional.ofNullable(configValue)
54+
.filter(candidate -> !candidate.isBlank())
55+
.map(
56+
candidate -> Arrays.stream(candidate.split(","))
57+
.map(StringNormalizer::normalize)
58+
.filter(s -> !s.isEmpty())
59+
.collect(Collectors.toSet()))
60+
.orElse(Collections.EMPTY_SET);
61+
}
62+
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
package de.symeda.sormas.backend.patch;
2+
3+
import static org.junit.jupiter.api.Assertions.assertFalse;
4+
import static org.junit.jupiter.api.Assertions.assertTrue;
5+
import static org.mockito.Mockito.when;
6+
7+
import org.junit.jupiter.api.Test;
8+
import org.junit.jupiter.params.ParameterizedTest;
9+
import org.junit.jupiter.params.provider.CsvSource;
10+
import org.mockito.InjectMocks;
11+
import org.mockito.Mock;
12+
13+
import de.symeda.sormas.api.symptoms.SymptomState;
14+
import de.symeda.sormas.api.systemconfiguration.SystemConfigurationValueFacade;
15+
import de.symeda.sormas.api.utils.YesNoUnknown;
16+
import de.symeda.sormas.backend.AbstractUnitTest;
17+
18+
class EqualValueOverrideHelperTest extends AbstractUnitTest {
19+
20+
@InjectMocks
21+
private EqualValueOverrideHelper victim;
22+
23+
@Mock
24+
private SystemConfigurationValueFacade systemConfigurationValueFacade;
25+
26+
@Test
27+
void allowedOverride_configuredValueMatchesPlainValue_returnsTrue() {
28+
// PREPARE
29+
when(systemConfigurationValueFacade.getValue(EqualValueOverrideHelper.ALLOWED_EQUALITY_VALUE_OVERRIDE_KEY)).thenReturn("UNKNOWN, OTHER");
30+
31+
// EXECUTE
32+
boolean result = victim.allowedOverride("UNKNOWN");
33+
34+
// CHECK
35+
assertTrue(result);
36+
}
37+
38+
@Test
39+
void allowedOverride_configuredValueMatchesTypePrefixedValue_returnsTrue() {
40+
// PREPARE
41+
when(systemConfigurationValueFacade.getValue(EqualValueOverrideHelper.ALLOWED_EQUALITY_VALUE_OVERRIDE_KEY)).thenReturn("Integer___42");
42+
43+
// EXECUTE
44+
boolean result = victim.allowedOverride(42);
45+
46+
// CHECK
47+
assertTrue(result);
48+
}
49+
50+
@Test
51+
void allowedOverride_configuredValueMatchesTypePrefixedValue_unknown_returnsTrue() {
52+
// PREPARE
53+
when(systemConfigurationValueFacade.getValue(EqualValueOverrideHelper.ALLOWED_EQUALITY_VALUE_OVERRIDE_KEY))
54+
.thenReturn("YesNoUnknown___UNKNOWN");
55+
56+
// EXECUTE & CHECK
57+
assertTrue(victim.allowedOverride(YesNoUnknown.UNKNOWN));
58+
assertFalse(victim.allowedOverride(SymptomState.UNKNOWN));
59+
}
60+
61+
@Test
62+
void allowedOverride_configuredValueDoesNotMatch_returnsFalse() {
63+
// PREPARE
64+
when(systemConfigurationValueFacade.getValue(EqualValueOverrideHelper.ALLOWED_EQUALITY_VALUE_OVERRIDE_KEY)).thenReturn("SOME_OTHER_VALUE");
65+
66+
// EXECUTE
67+
boolean result = victim.allowedOverride("UNKNOWN");
68+
69+
// CHECK
70+
assertFalse(result);
71+
}
72+
73+
@Test
74+
void allowedOverride_configValueNull_valueNotInDefaults_returnsFalse() {
75+
// PREPARE
76+
when(systemConfigurationValueFacade.getValue(EqualValueOverrideHelper.ALLOWED_EQUALITY_VALUE_OVERRIDE_KEY)).thenReturn(null);
77+
78+
// EXECUTE
79+
boolean result = victim.allowedOverride("NotAForbiddenField");
80+
81+
// CHECK
82+
assertFalse(result);
83+
}
84+
85+
@Test
86+
void allowedOverride_configValueWithWhitespaceAroundEntries_trimsEntries() {
87+
// PREPARE
88+
when(systemConfigurationValueFacade.getValue(EqualValueOverrideHelper.ALLOWED_EQUALITY_VALUE_OVERRIDE_KEY)).thenReturn(" foo , bar ,, ");
89+
90+
// EXECUTE & CHECK
91+
assertTrue(victim.allowedOverride("foo"));
92+
assertTrue(victim.allowedOverride("bar"));
93+
}
94+
95+
@ParameterizedTest
96+
@CsvSource({
97+
"FOO, foo",
98+
"foo, FOO",
99+
"Foo, fOO",
100+
"café, cafe",
101+
"CAFÉ, cafe",
102+
"' foo ', foo",
103+
"'Café Au Lait', cafe au lait" })
104+
void allowedOverride_configuredAndInputValuesDifferByCaseAccentsOrWhitespace_stillMatch(String configuredValue, String inputValue) {
105+
// PREPARE
106+
when(systemConfigurationValueFacade.getValue(EqualValueOverrideHelper.ALLOWED_EQUALITY_VALUE_OVERRIDE_KEY)).thenReturn(configuredValue);
107+
108+
// EXECUTE
109+
boolean result = victim.allowedOverride(inputValue);
110+
111+
// CHECK
112+
assertTrue(result);
113+
}
114+
115+
@Test
116+
void allowedOverride_typePrefixedConfiguredValueDifferentCase_stillMatches() {
117+
// PREPARE
118+
when(systemConfigurationValueFacade.getValue(EqualValueOverrideHelper.ALLOWED_EQUALITY_VALUE_OVERRIDE_KEY)).thenReturn("INTEGER___42");
119+
120+
// EXECUTE
121+
boolean result = victim.allowedOverride(42);
122+
123+
// CHECK
124+
assertTrue(result);
125+
}
126+
127+
@Test
128+
void allowedOverride_normalizedValuesDifferBeyondCaseAccentsOrWhitespace_returnsFalse() {
129+
// PREPARE
130+
when(systemConfigurationValueFacade.getValue(EqualValueOverrideHelper.ALLOWED_EQUALITY_VALUE_OVERRIDE_KEY)).thenReturn("FOO");
131+
132+
// EXECUTE
133+
boolean result = victim.allowedOverride("foobar");
134+
135+
// CHECK
136+
assertFalse(result);
137+
}
138+
}

sormas-backend/src/test/java/de/symeda/sormas/patch/DataPatcherImplTest.java

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,12 @@
2727
import de.symeda.sormas.api.caze.Vaccine;
2828
import de.symeda.sormas.api.customizableenum.CustomizableEnumTranslation;
2929
import de.symeda.sormas.api.customizableenum.CustomizableEnumType;
30-
import de.symeda.sormas.api.customizablefield.*;
30+
import de.symeda.sormas.api.customizablefield.CustomizableFieldContext;
31+
import de.symeda.sormas.api.customizablefield.CustomizableFieldGroup;
32+
import de.symeda.sormas.api.customizablefield.CustomizableFieldMetadataDto;
33+
import de.symeda.sormas.api.customizablefield.CustomizableFieldMetadataFacade;
34+
import de.symeda.sormas.api.customizablefield.CustomizableFieldType;
35+
import de.symeda.sormas.api.customizablefield.CustomizableFieldValueDto;
3136
import de.symeda.sormas.api.exposure.ExposureDto;
3237
import de.symeda.sormas.api.exposure.ExposureType;
3338
import de.symeda.sormas.api.externalmessage.survey.PatchDictionary;
@@ -40,9 +45,23 @@
4045
import de.symeda.sormas.api.infrastructure.country.CountryDto;
4146
import de.symeda.sormas.api.infrastructure.country.CountryReferenceDto;
4247
import de.symeda.sormas.api.infrastructure.facility.FacilityDto;
43-
import de.symeda.sormas.api.patch.*;
44-
import de.symeda.sormas.api.person.*;
48+
import de.symeda.sormas.api.patch.CaseDataPatchRequest;
49+
import de.symeda.sormas.api.patch.DataPatchFailure;
50+
import de.symeda.sormas.api.patch.DataPatchFailureCause;
51+
import de.symeda.sormas.api.patch.DataPatchResponse;
52+
import de.symeda.sormas.api.patch.DataPatcher;
53+
import de.symeda.sormas.api.patch.DataReplacementStrategy;
54+
import de.symeda.sormas.api.patch.EmptyValueBehavior;
55+
import de.symeda.sormas.api.person.OccupationType;
56+
import de.symeda.sormas.api.person.PersonContactDetailDto;
57+
import de.symeda.sormas.api.person.PersonContactDetailType;
58+
import de.symeda.sormas.api.person.PersonDto;
59+
import de.symeda.sormas.api.person.PhoneNumberType;
60+
import de.symeda.sormas.api.person.Sex;
4561
import de.symeda.sormas.api.symptoms.SymptomState;
62+
import de.symeda.sormas.api.systemconfiguration.SystemConfigurationCategoryReferenceDto;
63+
import de.symeda.sormas.api.systemconfiguration.SystemConfigurationValueDto;
64+
import de.symeda.sormas.api.utils.DataHelper;
4665
import de.symeda.sormas.api.utils.YesNoUnknown;
4766
import de.symeda.sormas.api.vaccination.VaccinationDto;
4867
import de.symeda.sormas.backend.AbstractBeanTest;
@@ -51,6 +70,9 @@
5170
import de.symeda.sormas.backend.customizableenum.CustomizableEnumValue;
5271
import de.symeda.sormas.backend.customizablefield.CustomizableFieldMetadataFacadeEjb;
5372
import de.symeda.sormas.backend.customizablefield.CustomizableFieldValueFacadeEjb;
73+
import de.symeda.sormas.backend.patch.EqualValueOverrideHelper;
74+
import de.symeda.sormas.backend.systemconfiguration.SystemConfigurationCategory;
75+
import de.symeda.sormas.backend.systemconfiguration.SystemConfigurationCategoryService;
5476

5577
class DataPatcherImplTest extends AbstractBeanTest {
5678

@@ -725,6 +747,51 @@ void patch_ifNotAlreadyPresent_sameDayDifferentTime_noForbiddenValueOverride() {
725747
() -> Assertions.assertTrue(response.isApplied()));
726748
}
727749

750+
@Test
751+
void patch_ifNotAlreadyPresent_equalValueOverrideConfiguredForType_realSystemConfigurationAllowsOverride() {
752+
// PREPARE
753+
CaseDataDto originalCase = creator.createUnclassifiedCase(Disease.PERTUSSIS);
754+
originalCase.getEpiData().setExposureDetailsKnown(YesNoUnknown.UNKNOWN);
755+
getCaseFacade().save(originalCase);
756+
757+
setAllowedEqualityValueOverride("YesNoUnknown___UNKNOWN");
758+
759+
CaseDataPatchRequest request =
760+
new CaseDataPatchRequest().setCaseUuid(originalCase.getUuid()).setPatchDictionary(Map.of("EpiData.exposureDetailsKnown", "YES"));
761+
762+
// EXECUTE
763+
DataPatchResponse response = victim().patch(request);
764+
765+
// CHECK
766+
CaseDataDto actualCase = getCaseFacade().getByUuid(originalCase.getUuid());
767+
Assertions.assertAll(
768+
() -> Assertions.assertTrue(response.getFailures().isEmpty(), "Failures: " + response.getFailures()),
769+
() -> Assertions.assertTrue(response.isApplied()),
770+
() -> Assertions.assertEquals(YesNoUnknown.YES, actualCase.getEpiData().getExposureDetailsKnown()));
771+
}
772+
773+
private void setAllowedEqualityValueOverride(String overridableValue) {
774+
SystemConfigurationCategory category;
775+
try {
776+
category = getSystemConfigurationCategoryService().getDefaultCategory();
777+
} catch (IllegalStateException e) {
778+
category = new SystemConfigurationCategory();
779+
category.setUuid(DataHelper.createUuid());
780+
category.setName(SystemConfigurationCategoryService.DEFAULT_CATEGORY_NAME);
781+
getSystemConfigurationCategoryService().ensurePersisted(category);
782+
}
783+
784+
SystemConfigurationValueDto configValue = new SystemConfigurationValueDto();
785+
786+
configValue.setUuid(DataHelper.createUuid());
787+
configValue.setKey(EqualValueOverrideHelper.ALLOWED_EQUALITY_VALUE_OVERRIDE_KEY);
788+
configValue.setValue(overridableValue);
789+
configValue.setEncrypt(false);
790+
configValue.setCategory(new SystemConfigurationCategoryReferenceDto(category.getUuid()));
791+
792+
getSystemConfigurationValueFacade().save(configValue);
793+
}
794+
728795
@Test
729796
void patch_hospitalization() {
730797
// PREPARE

0 commit comments

Comments
 (0)