Skip to content

Commit 2aa57d7

Browse files
AthiraHari77athira
andauthored
[Incubator kie issues#1882] DMN: Return full path of invalid element (until its root) from jit-executor (#2206)
* [incubator-kie-issues#1882] WIP * [incubator-kie-issues#1882] WIP * [incubator-kie-issues#1882] WIP * [incubator-kie-issues#1882] update models * [incubator-kie-issues#1882] update testcases * [incubator-kie-issues#1882] update testcases * [incubator-kie-issues#1882] Fix review comments * [incubator-kie-issues#1882] code refactoring * [incubator-kie-issues#1882] code refactoring * [incubator-kie-issues#1882] code formatted * [incubator-kie-issues#1882] code formatted * [incubator-kie-issues#1882] code formatted * [incubator-kie-issues#1882] sort imports * [incubator-kie-issues#1882] Remove duplicate paths * [incubator-kie-issues#1882] Remove duplicate paths * [incubator-kie-issues#1882] Fix review comments * [incubator-kie-issues#1882] Code formatted * [incubator-kie-issues#1882] Test cases updated --------- Co-authored-by: athira <athira77@ibm.com>
1 parent abf762f commit 2aa57d7

File tree

6 files changed

+283
-10
lines changed

6 files changed

+283
-10
lines changed

jitexecutor/jitexecutor-dmn/src/main/java/org/kie/kogito/jitexecutor/dmn/DMNEvaluator.java

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,13 @@
1919
package org.kie.kogito.jitexecutor.dmn;
2020

2121
import java.io.StringReader;
22+
import java.util.ArrayList;
2223
import java.util.Collection;
2324
import java.util.Collections;
2425
import java.util.HashMap;
2526
import java.util.List;
2627
import java.util.Map;
28+
import java.util.Objects;
2729
import java.util.Optional;
2830
import java.util.stream.Collectors;
2931

@@ -38,6 +40,10 @@
3840
import org.kie.dmn.core.impl.DMNRuntimeImpl;
3941
import org.kie.dmn.core.internal.utils.DMNRuntimeBuilder;
4042
import org.kie.dmn.core.internal.utils.DynamicDMNContextBuilder;
43+
import org.kie.dmn.model.api.ChildExpression;
44+
import org.kie.dmn.model.api.DMNElement;
45+
import org.kie.dmn.model.api.DMNModelInstrumentedBase;
46+
import org.kie.dmn.model.api.Definitions;
4147
import org.kie.internal.io.ResourceFactory;
4248
import org.kie.kogito.jitexecutor.common.requests.MultipleResourcesPayload;
4349
import org.kie.kogito.jitexecutor.common.requests.ResourceWithURI;
@@ -95,6 +101,74 @@ static DMNEvaluator validateForErrors(DMNModel dmnModel, DMNRuntime dmnRuntime)
95101
}
96102
}
97103

104+
static List<String> getPathToRoot(DMNModel dmnModel, String invalidId) {
105+
List<String> path = new ArrayList<>();
106+
DMNModelInstrumentedBase node = getNodeById(dmnModel, invalidId);
107+
108+
while (node != null && !(node instanceof Definitions)) {
109+
if (node instanceof DMNElement dmnElement) {
110+
path.add(dmnElement.getId());
111+
} else if (node instanceof ChildExpression childExpression) {
112+
path.add(childExpression.getId());
113+
} else {
114+
path.add(node.getIdentifierString());
115+
}
116+
node = node.getParent();
117+
}
118+
Collections.reverse(path);
119+
return path.isEmpty() ? Collections.singletonList(invalidId) : path;
120+
}
121+
122+
static DMNModelInstrumentedBase getNodeById(DMNModel dmnModel, String id) {
123+
return dmnModel.getDefinitions().getChildren().stream().map(child -> getNodeById(child, id))
124+
.filter(Objects::nonNull).findFirst().orElse(null);
125+
}
126+
127+
static DMNModelInstrumentedBase getNodeById(DMNModelInstrumentedBase dmnModelInstrumentedBase, String id) {
128+
if (dmnModelInstrumentedBase.getIdentifierString().equals(id)) {
129+
return dmnModelInstrumentedBase;
130+
}
131+
for (DMNModelInstrumentedBase child : dmnModelInstrumentedBase.getChildren()) {
132+
DMNModelInstrumentedBase result = getNodeById(child, id);
133+
if (result != null) {
134+
return result;
135+
}
136+
}
137+
return null;
138+
}
139+
140+
static List<List<String>> retrieveInvalidElementPaths(List<DMNMessage> messages, DMNModel dmnModel) {
141+
List<List<String>> invalidElementPaths = messages.stream().filter(message -> message.getLevel().equals(Message.Level.WARNING) ||
142+
message.getLevel().equals(Message.Level.ERROR)).map(message -> getPathToRoot(dmnModel, message.getSourceId())).collect(Collectors.toList());
143+
return removeDuplicates(invalidElementPaths);
144+
}
145+
146+
static List<List<String>> removeDuplicates(List<List<String>> invalidElementPaths) {
147+
invalidElementPaths.sort((a, b) -> Integer.compare(b.size(), a.size()));
148+
List<List<String>> result = new ArrayList<>();
149+
Map<List<String>, String> pathToStringMap = new HashMap<>();
150+
151+
for (List<String> invalidPath : invalidElementPaths) {
152+
String mergedInvalidPath = pathToStringMap.computeIfAbsent(invalidPath, DMNEvaluator::getMergedPaths);
153+
boolean isSubset = false;
154+
for (List<String> path : result) {
155+
String mergedPath = pathToStringMap.computeIfAbsent(path, DMNEvaluator::getMergedPaths);
156+
if (mergedPath.contains(mergedInvalidPath)) {
157+
isSubset = true;
158+
break;
159+
}
160+
}
161+
if (!isSubset) {
162+
result.add(invalidPath);
163+
}
164+
}
165+
return result;
166+
}
167+
168+
static String getMergedPaths(List<String> toMerge) {
169+
return String.join("|", toMerge);
170+
}
171+
98172
private DMNEvaluator(DMNModel dmnModel, DMNRuntime dmnRuntime) {
99173
this.dmnModel = dmnModel;
100174
this.dmnRuntime = dmnRuntime;
@@ -121,13 +195,14 @@ public JITDMNResult evaluate(Map<String, Object> context) {
121195
DMNContext dmnContext =
122196
new DynamicDMNContextBuilder(dmnRuntime.newContext(), dmnModel).populateContextWith(context);
123197
DMNResult dmnResult = dmnRuntime.evaluateAll(dmnModel, dmnContext);
198+
List<List<String>> invalidElementPaths = retrieveInvalidElementPaths(dmnResult.getMessages(), dmnModel);
124199
Optional<Map<String, Map<String, Integer>>> decisionEvaluationHitIdsMap = dmnRuntime.getListeners().stream()
125200
.filter(JITDMNListener.class::isInstance)
126201
.findFirst()
127202
.map(JITDMNListener.class::cast)
128203
.map(JITDMNListener::getDecisionEvaluationHitIdsMap);
129204
return JITDMNResult.of(getNamespace(), getName(), dmnResult,
130-
decisionEvaluationHitIdsMap.orElse(Collections.emptyMap()));
205+
decisionEvaluationHitIdsMap.orElse(Collections.emptyMap()), invalidElementPaths);
131206
}
132207

133208
}

jitexecutor/jitexecutor-dmn/src/main/java/org/kie/kogito/jitexecutor/dmn/JITDMNServiceImpl.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,14 +119,14 @@ public DMNResultWithExplanation evaluateModelAndExplain(DMNEvaluator dmnEvaluato
119119
Thread.currentThread().interrupt();
120120
}
121121
return new DMNResultWithExplanation(
122-
JITDMNResult.of(dmnEvaluator.getNamespace(), dmnEvaluator.getName(), dmnResult, Collections.emptyMap()),
122+
JITDMNResult.of(dmnEvaluator.getNamespace(), dmnEvaluator.getName(), dmnResult, Collections.emptyMap(), dmnResult.getInvalidElementPaths()),
123123
new SalienciesResponse(EXPLAINABILITY_FAILED, EXPLAINABILITY_FAILED_MESSAGE, null));
124124
}
125125

126126
List<SaliencyResponse> saliencyModelResponse = buildSalienciesResponse(dmnEvaluator.getDmnModel(), saliencyMap);
127127

128128
return new DMNResultWithExplanation(
129-
JITDMNResult.of(dmnEvaluator.getNamespace(), dmnEvaluator.getName(), dmnResult, Collections.emptyMap()),
129+
JITDMNResult.of(dmnEvaluator.getNamespace(), dmnEvaluator.getName(), dmnResult, Collections.emptyMap(), dmnResult.getInvalidElementPaths()),
130130
new SalienciesResponse(EXPLAINABILITY_SUCCEEDED, null, saliencyModelResponse));
131131
}
132132

jitexecutor/jitexecutor-dmn/src/main/java/org/kie/kogito/jitexecutor/dmn/responses/JITDMNResult.java

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,13 +51,17 @@ public class JITDMNResult implements Serializable,
5151

5252
private List<DMNDecisionResult> decisionResults;
5353

54-
public static JITDMNResult of(String namespace, String modelName, org.kie.dmn.api.core.DMNResult dmnResult, Map<String, Map<String, Integer>> decisionEvaluationHitIdsMap) {
54+
private List<List<String>> invalidElementPaths;
55+
56+
public static JITDMNResult of(String namespace, String modelName, org.kie.dmn.api.core.DMNResult dmnResult, Map<String, Map<String, Integer>> decisionEvaluationHitIdsMap,
57+
List<List<String>> invalidElementPaths) {
5558
JITDMNResult toReturn = new JITDMNResult();
5659
toReturn.namespace = namespace;
5760
toReturn.modelName = modelName;
5861
toReturn.dmnContext = internalGetContext(dmnResult.getContext().getAll());
5962
toReturn.messages = internalGetMessages(dmnResult.getMessages());
6063
toReturn.decisionResults = internalGetDecisionResults(dmnResult.getDecisionResults(), decisionEvaluationHitIdsMap);
64+
toReturn.invalidElementPaths = invalidElementPaths;
6165
return toReturn;
6266
}
6367

@@ -93,6 +97,14 @@ public void setMessages(List<JITDMNMessage> messages) {
9397
this.messages = messages;
9498
}
9599

100+
public List<List<String>> getInvalidElementPaths() {
101+
return invalidElementPaths;
102+
}
103+
104+
public void setInvalidElementPaths(List<List<String>> invalidElementPaths) {
105+
this.invalidElementPaths = invalidElementPaths;
106+
}
107+
96108
@JsonIgnore
97109
@Override
98110
public DMNContext getContext() {
@@ -163,6 +175,7 @@ public String toString() {
163175
.append(", dmnContext=").append(dmnContext)
164176
.append(", messages=").append(messages)
165177
.append(", decisionResults=").append(decisionResults)
178+
.append(", invalidPaths=").append(invalidElementPaths)
166179
.append("]").toString();
167180
}
168181

jitexecutor/jitexecutor-dmn/src/test/java/org/kie/kogito/jitexecutor/dmn/DMNEvaluatorTest.java

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,29 @@
1919
package org.kie.kogito.jitexecutor.dmn;
2020

2121
import java.io.IOException;
22+
import java.util.Arrays;
2223
import java.util.Collections;
24+
import java.util.List;
25+
import java.util.stream.Stream;
2326

2427
import org.junit.jupiter.api.BeforeAll;
2528
import org.junit.jupiter.api.Test;
29+
import org.junit.jupiter.params.ParameterizedTest;
30+
import org.junit.jupiter.params.provider.Arguments;
31+
import org.junit.jupiter.params.provider.MethodSource;
32+
import org.kie.api.io.Resource;
33+
import org.kie.dmn.api.core.DMNContext;
2634
import org.kie.dmn.api.core.DMNMessage;
2735
import org.kie.dmn.api.core.DMNModel;
36+
import org.kie.dmn.api.core.DMNResult;
2837
import org.kie.dmn.api.core.DMNRuntime;
38+
import org.kie.dmn.core.api.DMNFactory;
2939
import org.kie.dmn.core.impl.DMNRuntimeImpl;
40+
import org.kie.dmn.core.internal.utils.DMNRuntimeBuilder;
41+
import org.kie.dmn.model.api.DMNModelInstrumentedBase;
42+
import org.kie.internal.io.ResourceFactory;
3043

44+
import static org.assertj.core.api.Assertions.assertThat;
3145
import static org.junit.jupiter.api.Assertions.assertNotNull;
3246
import static org.junit.jupiter.api.Assertions.assertThrows;
3347
import static org.kie.kogito.jitexecutor.dmn.TestingUtils.getModelFromIoUtils;
@@ -88,4 +102,117 @@ void testValidateForErrors() {
88102
assertNotNull(evaluator);
89103
}
90104

105+
@Test
106+
void testRetrieveInvalidElementPaths() throws IOException {
107+
Resource resource = ResourceFactory.newClassPathResource("invalid_models/DMNv1_5/DMN-MultipleInvalidElements.dmn");
108+
DMNRuntime dmnRuntime = DMNRuntimeBuilder.fromDefaults().buildConfiguration()
109+
.fromResources(Collections.singletonList(resource)).getOrElseThrow(RuntimeException::new);
110+
assertThat(dmnRuntime).isNotNull();
111+
String nameSpace = "https://kie.org/dmn/_79591DB5-1EE1-4CBD-AA5D-2E3EDF31150E";
112+
113+
final DMNModel dmnModel = dmnRuntime.getModel(nameSpace, "DMN_8F7C4323-412A-4E0B-9AEF-0F24C8F55282");
114+
assertThat(dmnModel).isNotNull();
115+
DMNContext dmnContext = DMNFactory.newContext();
116+
dmnContext.set("id", "_7273EA2E-2CC3-4012-8F87-39E310C8DF3C");
117+
dmnContext.set("Conditional Input", 107);
118+
dmnContext.set("New Input Data", 8888);
119+
dmnContext.set("Score", 8);
120+
DMNResult dmnResult = dmnRuntime.evaluateAll(dmnModel, dmnContext);
121+
List<List<String>> invalidElementPaths = List.of(
122+
List.of("_3DC41DB9-BE1D-4289-A639-24AB57ED082D", "_2B147ECC-2457-4623-B841-3360D75F9F76", "_6F318F57-DA06-4F71-80AD-288E0BBB3A52", "_43236F2B-9857-454F-8EA0-39B37C7519CF"),
123+
List.of("_09186183-0646-4CD0-AD67-A159E9F87F5E", "_D386D137-582B-49F9-B6F9-F341C3AC4B3E", "_2E43C09D-011A-436C-B40B-9154405EAF3A"),
124+
List.of("_A40F3AA4-2832-4D98-83F0-7D604F9A090F", "_4AC1BD7D-5A8D-4A88-94F9-0B80BDF0D9B1"), List.of("_E9468D45-51EB-48DA-8B30-7D65696FDFB8"));
125+
126+
List<List<String>> retrieved = DMNEvaluator.retrieveInvalidElementPaths(dmnResult.getMessages(), dmnModel);
127+
assertNotNull(retrieved);
128+
assertThat(invalidElementPaths.size()).isEqualTo(retrieved.size());
129+
assertThat(invalidElementPaths).isEqualTo(retrieved);
130+
}
131+
132+
@Test
133+
void testGetPathToRoot() {
134+
Resource resource = ResourceFactory.newClassPathResource("invalid_models/DMNv1_5/InvalidElementPath.dmn");
135+
DMNRuntime dmnRuntime = DMNRuntimeBuilder.fromDefaults().buildConfiguration()
136+
.fromResources(Collections.singletonList(resource)).getOrElseThrow(RuntimeException::new);
137+
assertThat(dmnRuntime).isNotNull();
138+
String nameSpace = "https://kie.org/dmn/_608570C5-8344-42B6-9538-6E0EA9892C38";
139+
140+
final DMNModel dmnModel = dmnRuntime.getModel(nameSpace, "DMN_039CBA90-29EC-4A15-B376-FC0FBC5F6807");
141+
assertThat(dmnModel).isNotNull();
142+
String id = "_8577FE15-1512-4BBE-885F-C30FD73ADC6B";
143+
List<String> invalidPath = List.of("_172F9901-0884-47C1-A5B4-3C09CC83D5B6", "_8577FE15-1512-4BBE-885F-C30FD73ADC6B");
144+
145+
List<String> retrieved = DMNEvaluator.getPathToRoot(dmnModel, id);
146+
147+
assertNotNull(retrieved);
148+
assertThat(invalidPath).isEqualTo(retrieved);
149+
}
150+
151+
@Test
152+
void testGetNode() {
153+
DMNModelInstrumentedBase dmnModelInstrumentedBaseNode = mock(DMNModelInstrumentedBase.class);
154+
Resource resource = ResourceFactory.newClassPathResource("invalid_models/DMNv1_5/DMN-MultipleInvalidElements.dmn");
155+
DMNRuntime dmnRuntime = DMNRuntimeBuilder.fromDefaults().buildConfiguration()
156+
.fromResources(Collections.singletonList(resource)).getOrElseThrow(RuntimeException::new);
157+
assertThat(dmnRuntime).isNotNull();
158+
String nameSpace = "https://kie.org/dmn/_79591DB5-1EE1-4CBD-AA5D-2E3EDF31150E";
159+
160+
final DMNModel dmnModel = dmnRuntime.getModel(nameSpace, "DMN_8F7C4323-412A-4E0B-9AEF-0F24C8F55282");
161+
assertThat(dmnModel).isNotNull();
162+
String id = "_43236F2B-9857-454F-8EA0-39B37C7519CF";
163+
when(dmnModelInstrumentedBaseNode.getIdentifierString()).thenReturn(id);
164+
165+
DMNModelInstrumentedBase node = DMNEvaluator.getNodeById(dmnModel, id);
166+
167+
assertNotNull(node);
168+
assertThat(dmnModelInstrumentedBaseNode.getIdentifierString()).isEqualTo(node.getIdentifierString());
169+
}
170+
171+
@Test
172+
void testGetNodeById() {
173+
DMNModelInstrumentedBase dmnModelInstrumentedBaseNode = mock(DMNModelInstrumentedBase.class);
174+
Resource resource = ResourceFactory.newClassPathResource("invalid_models/DMNv1_5/InvalidElementPath.dmn");
175+
DMNRuntime dmnRuntime = DMNRuntimeBuilder.fromDefaults().buildConfiguration()
176+
.fromResources(Collections.singletonList(resource)).getOrElseThrow(RuntimeException::new);
177+
assertThat(dmnRuntime).isNotNull();
178+
String nameSpace = "https://kie.org/dmn/_608570C5-8344-42B6-9538-6E0EA9892C38";
179+
180+
final DMNModel dmnModel = dmnRuntime.getModel(nameSpace, "DMN_039CBA90-29EC-4A15-B376-FC0FBC5F6807");
181+
assertThat(dmnModel).isNotNull();
182+
String id = "_8577FE15-1512-4BBE-885F-C30FD73ADC6B";
183+
when(dmnModelInstrumentedBaseNode.getIdentifierString()).thenReturn(id);
184+
185+
DMNModelInstrumentedBase node = DMNEvaluator.getNodeById(dmnModelInstrumentedBaseNode, id);
186+
187+
assertNotNull(node);
188+
assertThat(dmnModelInstrumentedBaseNode.getIdentifierString()).isEqualTo(node.getIdentifierString());
189+
}
190+
191+
@ParameterizedTest
192+
@MethodSource("provideParametersForRemoveDuplicates")
193+
void testRemoveDuplicates(List<List<String>> input, List<List<String>> expected) {
194+
List<List<String>> retrieved = DMNEvaluator.removeDuplicates(input);
195+
assertThat(expected.size()).isEqualTo(retrieved.size());
196+
assertThat(expected).isEqualTo(retrieved);
197+
}
198+
199+
private static Stream<Arguments> provideParametersForRemoveDuplicates() {
200+
return Stream.of(Arguments.of(Arrays.asList(List.of("A", "B", "D"), List.of("A", "B", "B", "D"), List.of("A", "B", "C", "D"), List.of("C", "B", "A"),
201+
List.of("A", "B", "C"), List.of("F", "G", "H", "I"), List.of("F", "H"), List.of("I", "H"), List.of("FG", "H", "I"), List.of("F", "GH")),
202+
Arrays.asList(List.of("A", "B", "B", "D"), List.of("A", "B", "C", "D"), List.of("F", "G", "H", "I"), List.of("A", "B", "D"), List.of("C", "B", "A"),
203+
List.of("FG", "H", "I"), List.of("F", "H"), List.of("I", "H"), List.of("F", "GH"))),
204+
// subset
205+
Arguments.of(Arrays.asList(List.of("A", "B", "C", "D"), List.of("A", "B"), List.of("B", "C"), List.of("C", "D")),
206+
List.of(List.of("A", "B", "C", "D"))),
207+
// all duplicates
208+
Arguments.of(Arrays.asList(List.of("A", "B", "C"), List.of("A", "B", "C"), List.of("A", "B", "C")),
209+
List.of(List.of("A", "B", "C"))),
210+
// no duplicates
211+
Arguments.of(Arrays.asList(List.of("A", "B", "C"), List.of("X", "Y", "Z")),
212+
Arrays.asList(List.of("A", "B", "C"), List.of("X", "Y", "Z"))),
213+
// one complete duplicate
214+
Arguments.of(Arrays.asList(List.of("A", "B", "C"), List.of("A", "B", "C"), List.of("X", "Y", "Z")),
215+
Arrays.asList(List.of("A", "B", "C"), List.of("X", "Y", "Z"))));
216+
}
217+
91218
}

jitexecutor/jitexecutor-dmn/src/test/java/org/kie/kogito/jitexecutor/dmn/api/JITDMNResourceTest.java

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,22 +47,21 @@
4747

4848
import static io.restassured.RestAssured.given;
4949
import static org.hamcrest.CoreMatchers.containsString;
50+
import static org.hamcrest.Matchers.hasItems;
5051
import static org.kie.kogito.jitexecutor.dmn.TestingUtils.getModelFromIoUtils;
5152

5253
@QuarkusTest
5354
public class JITDMNResourceTest {
5455

56+
static final String EVALUATION_HIT_IDS_FIELD_NAME = "evaluationHitIds";
57+
private static final ObjectMapper MAPPER = new ObjectMapper();
5558
private static String invalidModel1x;
5659
private static String invalidModel15;
5760
private static String validModel15;
5861
private static String modelWithExtensionElements;
5962
private static String modelWithMultipleEvaluationHitIds;
6063
private static String modelWithNestedConditionalEvaluationHitIds;
6164

62-
private static final ObjectMapper MAPPER = new ObjectMapper();
63-
64-
static final String EVALUATION_HIT_IDS_FIELD_NAME = "evaluationHitIds";
65-
6665
static {
6766
MAPPER.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
6867
}
@@ -288,6 +287,22 @@ void testjitdmnEvaluateAndExplainInvalidModel() {
288287
.body(containsString("Error compiling FEEL expression 'Person Age >= 18' for name 'Can Drive?' on node 'Can Drive?': syntax error near 'Age'"));
289288
}
290289

290+
@Test
291+
void testjitdmnRetrieveInvalidElementPath() throws IOException {
292+
Map<String, Object> context = new HashMap<>();
293+
context.put("id", "_641F6FB1-6720-425E-9045-7EB9B90E2FFF");
294+
context.put("New Input Data", 8888);
295+
JITDMNPayload jitdmnpayload = new JITDMNPayload(getModelFromIoUtils("invalid_models/DMNv1_5/InvalidElementPath.dmn"), context);
296+
given()
297+
.contentType(ContentType.JSON)
298+
.body(jitdmnpayload)
299+
.when().post("/jitdmn/dmnresult")
300+
.then()
301+
.statusCode(200)
302+
.body("invalidElementPaths", hasItems(List.of("_172F9901-0884-47C1-A5B4-3C09CC83D5B6", "_8577FE15-1512-4BBE-885F-C30FD73ADC6B"),
303+
List.of("_4FF85EFF-B9E6-41C3-9115-DC9690E3B6F7")));
304+
}
305+
291306
static Map<String, Object> buildMultipleHitContext() {
292307
final List<BigDecimal> numbers = new ArrayList<>();
293308
numbers.add(BigDecimal.valueOf(10));

0 commit comments

Comments
 (0)