Repository: dhis2/dhis2-core
Pull request #21008: fix: Check compulsory data when completing a DataSet [DHIS2-18295]
Author: login: david-mackessy
Created: 2025-05-20T11:27:49Z | Merged: 2025-05-22T13:37:34Z

## Issues 
1. The API `POST` `/api/dataEntry/dataSetCompletion` allows completing a `DataSet` when required compulsory data element operands are missing data.

2. ** Bonus bug **  While testing (same endpoint), another bug was found (after above fix was in place) whereby a `DataSet` with compulsory data elements, with data, could not be completed.

## Causes
1. There is no check for missing compulsory data element operands when completing a `DataSet`.

2. The `Period` being used in the SQL query to find data values had an ID of 0, resulting in no data being found when there is in fact data.


## Fix
1. Use an existing check for missing compulsory data element operands when completing a `DataSet`.

2. Load the `Period` so the correct `Period` ID is used in the SQL where clause.

## Testing
Automated controller tests added for:
- `DataSet` without any compulsory elements
- `DataSet` with compulsory elements & are required (no data filled)
- `DataSet` with compulsory elements & are required (with data filled)
- `DataSet` with compulsory elements & are not required

## Background Info
It seems the endpoint `POST` `/api/dataEntry/dataSetCompletion` is actually a [new-ish endpoint](#11673) for single `DataSet` completion.
The old (bulk) endpoint is `POST` `/api/completeDataSetRegistrations`, which does have the check for missing compulsory data element operands.


Diff:
diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/dataset/DefaultCompleteDataSetRegistrationService.java b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/dataset/DefaultCompleteDataSetRegistrationService.java
index bb7e906f74fe..76f1145d38a8 100644
--- a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/dataset/DefaultCompleteDataSetRegistrationService.java
+++ b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/dataset/DefaultCompleteDataSetRegistrationService.java
@@ -34,6 +34,7 @@
 import java.util.Date;
 import java.util.List;
 import java.util.Map;
+import java.util.stream.Collectors;
 import lombok.RequiredArgsConstructor;
 import org.hisp.dhis.category.CategoryOptionCombo;
 import org.hisp.dhis.category.CategoryService;
@@ -48,6 +49,7 @@
 import org.hisp.dhis.message.MessageService;
 import org.hisp.dhis.organisationunit.OrganisationUnit;
 import org.hisp.dhis.period.Period;
+import org.hisp.dhis.period.PeriodStore;
 import org.hisp.dhis.user.CurrentUserUtil;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
@@ -75,6 +77,8 @@ public class DefaultCompleteDataSetRegistrationService
 
   private final MessageService messageService;
 
+  private final PeriodStore periodStore;
+
   // -------------------------------------------------------------------------
   // CompleteDataSetRegistrationService
   // -------------------------------------------------------------------------
@@ -82,6 +86,9 @@ public class DefaultCompleteDataSetRegistrationService
   @Override
   @Transactional
   public void saveCompleteDataSetRegistration(CompleteDataSetRegistration registration) {
+    registration.setPeriod(periodStore.reloadForceAddPeriod(registration.getPeriod()));
+    checkCompulsoryDeOperands(registration);
+
     Date date = new Date();
 
     if (!registration.hasDate()) {
@@ -113,9 +120,41 @@ public void saveCompleteDataSetRegistration(CompleteDataSetRegistration registra
     notificationEventPublisher.publishEvent(registration);
   }
 
+  /**
+   * Check if a data set is missing data from its compulsory data element operands. DataSet has the
+   * flag compulsoryFieldsCompleteOnly, which when false, actually renders the compulsory elements
+   * not compulsory. That flag is checked first to see if the missing data element operands should
+   * be retrieved. <br>
+   * If there are compulsory elements missing data (and they are compulsory) then an exception is
+   * thrown advising that compulsory elements are required to be filled.
+   *
+   * @param registration registration to check
+   */
+  private void checkCompulsoryDeOperands(CompleteDataSetRegistration registration) {
+    // only get missing compulsory elements if they are actually compulsory
+    if (registration.getDataSet().isCompulsoryFieldsCompleteOnly()) {
+      List<DataElementOperand> missingDataElementOperands =
+          getMissingCompulsoryFields(
+              registration.getDataSet(),
+              registration.getPeriod(),
+              registration.getSource(),
+              registration.getAttributeOptionCombo());
+      if (!missingDataElementOperands.isEmpty()) {
+        String deos =
+            missingDataElementOperands.stream()
+                .map(DataElementOperand::getDisplayName)
+                .collect(Collectors.joining(","));
+        throw new IllegalStateException(
+            "All compulsory data element operands need to be filled: [%s]".formatted(deos));
+      }
+    }
+  }
+
   @Override
   @Transactional
   public void updateCompleteDataSetRegistration(CompleteDataSetRegistration registration) {
+    checkCompulsoryDeOperands(registration);
+
     registration.setLastUpdated(new Date());
 
     registration.setLastUpdatedBy(CurrentUserUtil.getCurrentUsername());
diff --git a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/DataSetCompletionControllerTest.java b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/DataSetCompletionControllerTest.java
new file mode 100644
index 000000000000..02215bb99a10
--- /dev/null
+++ b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/DataSetCompletionControllerTest.java
@@ -0,0 +1,289 @@
+/*
+ * Copyright (c) 2004-2025, University of Oslo
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice, this
+ * list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * 3. Neither the name of the copyright holder nor the names of its contributors 
+ * may be used to endorse or promote products derived from this software without
+ * specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package org.hisp.dhis.webapi.controller;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.hisp.dhis.common.UID;
+import org.hisp.dhis.http.HttpStatus;
+import org.hisp.dhis.jsontree.JsonMixed;
+import org.hisp.dhis.test.webapi.PostgresControllerIntegrationTestBase;
+import org.hisp.dhis.test.webapi.json.domain.JsonWebMessage;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+class DataSetCompletionControllerTest extends PostgresControllerIntegrationTestBase {
+
+  private String de;
+  private String ou;
+  private String ds;
+  private final String p = "202504";
+
+  @BeforeEach
+  void beforeEach() {
+    de = UID.generate().getValue();
+    ou = UID.generate().getValue();
+    ds = UID.generate().getValue();
+  }
+
+  @Test
+  @DisplayName(
+      "Complete data set not allowed when missing compulsory elements and compulsory elements are required")
+  void missingCompulsoryDataElementOperandsTest() {
+    // given a data set with compulsory data element operands
+    boolean compulsoryElementsAreRequired = true;
+    POST("/metadata", metadata(compulsoryElementsAreRequired, withCompulsoryElements()))
+        .content(HttpStatus.OK);
+    PATCH("/users/%s".formatted(ADMIN_USER_UID), addOrgUnit());
+
+    // when trying to complete the data set that has missing compulsory data
+    JsonWebMessage jsonWebMessage =
+        POST("/dataEntry/dataSetCompletion", completeDataSetReg())
+            .content(HttpStatus.CONFLICT)
+            .as(JsonWebMessage.class);
+
+    // then it should fail
+    assertEquals(
+        "All compulsory data element operands need to be filled: [test-de-1]",
+        jsonWebMessage.getMessage());
+
+    // and complete dataset reg should be empty
+    JsonMixed cdsr =
+        GET("/api/completeDataSetRegistrations?orgUnit=%s&period=%s&dataSet=%s"
+                .formatted(ou, p, ds))
+            .content(HttpStatus.OK);
+
+    assertTrue(cdsr.isEmpty());
+  }
+
+  @Test
+  @DisplayName("Complete data set allowed when compulsory elements are required and filled")
+  void compulsoryDataElementOperandsFilledTest() {
+    // given a data set with compulsory data element operands
+    boolean compulsoryElementsAreRequired = true;
+    POST("/metadata", metadata(compulsoryElementsAreRequired, withCompulsoryElements()))
+        .content(HttpStatus.OK);
+    PATCH("/users/%s".formatted(ADMIN_USER_UID), addOrgUnit());
+
+    // and data exists for the compulsory data element
+    POST("dataValueSets", dataValue()).content(HttpStatus.OK);
+
+    // when trying to complete the data set that has missing compulsory data, it should succeed
+    assertEquals(
+        HttpStatus.OK, POST("/dataEntry/dataSetCompletion", completeDataSetReg()).status());
+
+    // and complete dataset reg should be present
+    JsonMixed cdsr =
+        GET("/api/completeDataSetRegistrations?orgUnit=%s&period=%s&dataSet=%s"
+                .formatted(ou, p, ds))
+            .content(HttpStatus.OK);
+
+    assertEquals(
+        ds,
+        cdsr.getArray("completeDataSetRegistrations")
+            .get(0)
+            .asObject()
+            .getString("dataSet")
+            .string());
+  }
+
+  @Test
+  @DisplayName(
+      "Complete data set allowed when missing compulsory elements and compulsory elements are not required")
+  void missingCompulsoryDataElementOperandsNotCompulsoryTest() {
+    // given a data set with compulsory data element operands
+    boolean compulsoryElementsAreNotRequired = false;
+    POST("/metadata", metadata(compulsoryElementsAreNotRequired, withCompulsoryElements()))
+        .content(HttpStatus.OK);
+    PATCH("/users/%s".formatted(ADMIN_USER_UID), addOrgUnit());
+
+    // when trying to complete the data set that has missing compulsory data, it should succeed
+    assertEquals(
+        HttpStatus.OK, POST("/dataEntry/dataSetCompletion", completeDataSetReg()).status());
+
+    // and complete dataset reg should be present
+    JsonMixed cdsr =
+        GET("/api/completeDataSetRegistrations?orgUnit=%s&period=%s&dataSet=%s"
+                .formatted(ou, p, ds))
+            .content(HttpStatus.OK);
+
+    assertEquals(
+        ds,
+        cdsr.getArray("completeDataSetRegistrations")
+            .get(0)
+            .asObject()
+            .getString("dataSet")
+            .string());
+  }
+
+  @Test
+  @DisplayName("Complete data set is allowed when no compulsory data element operands")
+  void noCompulsoryDataElementOperandsTest() {
+    // given a data set with no compulsory data element operands
+    POST("/metadata", metadata(true, "")).content(HttpStatus.OK);
+    PATCH("/users/%s".formatted(ADMIN_USER_UID), addOrgUnit());
+
+    // when trying to complete the data set with no compulsory data, it should succeed
+    assertEquals(
+        HttpStatus.OK, POST("/dataEntry/dataSetCompletion", completeDataSetReg()).status());
+
+    // and complete dataset reg should have 1 entry
+    JsonMixed cdsr =
+        GET("/api/completeDataSetRegistrations?orgUnit=%s&period=%s&dataSet=%s"
+                .formatted(ou, p, ds))
+            .content(HttpStatus.OK);
+
+    assertEquals(
+        ds,
+        cdsr.getArray("completeDataSetRegistrations")
+            .get(0)
+            .asObject()
+            .getString("dataSet")
+            .string());
+  }
+
+  private String metadata(boolean compulsory, String withCompulsory) {
+    return """
+        {
+          "dataElements": [
+            {
+              "id": "%1$s",
+              "aggregationType": "DEFAULT",
+              "domainType": "AGGREGATE",
+              "name": "test-de-1",
+              "shortName": "test-de-1",
+              "valueType": "TEXT",
+              "categoryCombo": {
+                "id": "bjDvmb4bfuf"
+              }
+            }
+          ],
+          "organisationUnits": [
+            {
+              "id": "%2$s",
+              "name": "test-org-1",
+              "shortName": "test-org-1",
+              "openingDate": "2023-06-15"
+            }
+          ],
+          "dataSets": [
+            {
+              "id": "%3$s",
+              "name": "test-ds-1",
+              "shortName": "test-ds-1",
+              "periodType": "Monthly",
+              "compulsoryFieldsCompleteOnly": %4$b,
+              "dataSetElements": [
+                {
+                  "dataElement": {
+                    "id": "%1$s"
+                  }
+                }
+              ],
+              "organisationUnits": [
+                {
+                  "id": "%2$s"
+                }
+              ]%5$s
+            }
+          ]
+        }
+        """
+        .formatted(de, ou, ds, compulsory, withCompulsory);
+  }
+
+  private String withCompulsoryElements() {
+    return """
+            ,
+            "compulsoryDataElementOperands": [
+              {
+                "dataElement": {
+                    "id": "%1$s"
+                },
+                "categoryOptionCombo": {
+                    "id": "HllvX50cXC0"
+                }
+              }
+            ]"""
+        .formatted(de);
+  }
+
+  private String addOrgUnit() {
+    return """
+        [
+          {
+            "op": "add",
+            "path": "/organisationUnits",
+            "value": [
+              {
+                "id": "%s"
+              }
+            ]
+          }
+        ]
+        """
+        .formatted(ou);
+  }
+
+  private String completeDataSetReg() {
+    return """
+        {
+            "dataSet": "%s",
+            "period": "%s",
+            "orgUnit": "%s",
+            "attribute": {},
+            "completed": true
+        }
+        """
+        .formatted(ds, p, ou);
+  }
+
+  private String dataValue() {
+    return """
+          {
+            "dataSet": "%1$s",
+            "period": "%4$s",
+            "orgUnit": "%2$s",
+            "completedDate": "2025-05-25",
+            "dataValues": [
+              {
+                "dataElement": "%3$s",
+                "period": "%4$s",
+                "orgUnit": "%2$s",
+                "value": "2000",
+                "followup": false
+              }
+            ]
+          }"""
+        .formatted(ds, ou, de, p);
+  }
+}


Reviews:
user: login: jbee
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T10:39:00Z
user: login: larshelge
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-21T16:22:11Z
user: login: jbee
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T09:12:59Z
user: login: david-mackessy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T12:04:20Z
user: login: netroms
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T13:37:32Z

Review comments:
diff_hunk: @@ -113,9 +120,41 @@ public void saveCompleteDataSetRegistration(CompleteDataSetRegistration registra
     notificationEventPublisher.publishEvent(registration);
   }
 
+  /**
+   * Check if a data set is missing data from its compulsory data element operands. DataSet has the
+   * flag compulsoryFieldsCompleteOnly, which when false, actually renders the compulsory elements
+   * not compulsory. That flag is checked first to see if the missing data element operands should
+   * be retrieved. <br>
+   * If there are compulsory elements missing data (and they are compulsory) then an exception is
+   * thrown advising that compulsory elements are required to be filled.
+   *
+   * @param registration registration to check
+   */
+  private void checkCompulsoryDeOperands(CompleteDataSetRegistration registration) {
+    // only get missing compulsory elements if they are actually compulsory
+    if (registration.getDataSet().isCompulsoryFieldsCompleteOnly()) {
+      List<DataElementOperand> missingDataElementOperands =
+          getMissingCompulsoryFields(
+              registration.getDataSet(),
+              registration.getPeriod(),
+              registration.getSource(),
+              registration.getAttributeOptionCombo());
+      if (!missingDataElementOperands.isEmpty()) {
+        String deos =
+            missingDataElementOperands.stream()
+                .map(DataElementOperand::getDisplayName)
+                .collect(Collectors.joining(","));
+        throw new IllegalStateException(
+            "All compulsory data element operands need to be filled: [%s]".formatted(deos));
path: dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/dataset/DefaultCompleteDataSetRegistrationService.java
user: login: larshelge
body: Should we instead throw an exception with an `ErrorCode` here to make it consistent with other validation errors, and allow programmatic client handling? This state is not a "programming error" and could happen e.g. due to user error in an external client.
created_at: 2025-05-21T16:22:11Z
author_association: MEMBER
diff_hunk: @@ -113,9 +120,41 @@ public void saveCompleteDataSetRegistration(CompleteDataSetRegistration registra
     notificationEventPublisher.publishEvent(registration);
   }
 
+  /**
+   * Check if a data set is missing data from its compulsory data element operands. DataSet has the
+   * flag compulsoryFieldsCompleteOnly, which when false, actually renders the compulsory elements
+   * not compulsory. That flag is checked first to see if the missing data element operands should
+   * be retrieved. <br>
+   * If there are compulsory elements missing data (and they are compulsory) then an exception is
+   * thrown advising that compulsory elements are required to be filled.
+   *
+   * @param registration registration to check
+   */
+  private void checkCompulsoryDeOperands(CompleteDataSetRegistration registration) {
+    // only get missing compulsory elements if they are actually compulsory
+    if (registration.getDataSet().isCompulsoryFieldsCompleteOnly()) {
+      List<DataElementOperand> missingDataElementOperands =
+          getMissingCompulsoryFields(
+              registration.getDataSet(),
+              registration.getPeriod(),
+              registration.getSource(),
+              registration.getAttributeOptionCombo());
+      if (!missingDataElementOperands.isEmpty()) {
+        String deos =
+            missingDataElementOperands.stream()
+                .map(DataElementOperand::getDisplayName)
+                .collect(Collectors.joining(","));
+        throw new IllegalStateException(
+            "All compulsory data element operands need to be filled: [%s]".formatted(deos));
path: dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/dataset/DefaultCompleteDataSetRegistrationService.java
user: login: jbee
body: I was about to also leave a comment on error handling but I didn't because I think most if the code for this endpoint/operation does not really follow our more recent goals and I think we better make a PR that moves all the error handling in one PR instead of making it a mixed solution by doing this one in line while other error handling is not.
created_at: 2025-05-22T09:12:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -113,9 +120,41 @@ public void saveCompleteDataSetRegistration(CompleteDataSetRegistration registra
     notificationEventPublisher.publishEvent(registration);
   }
 
+  /**
+   * Check if a data set is missing data from its compulsory data element operands. DataSet has the
+   * flag compulsoryFieldsCompleteOnly, which when false, actually renders the compulsory elements
+   * not compulsory. That flag is checked first to see if the missing data element operands should
+   * be retrieved. <br>
+   * If there are compulsory elements missing data (and they are compulsory) then an exception is
+   * thrown advising that compulsory elements are required to be filled.
+   *
+   * @param registration registration to check
+   */
+  private void checkCompulsoryDeOperands(CompleteDataSetRegistration registration) {
+    // only get missing compulsory elements if they are actually compulsory
+    if (registration.getDataSet().isCompulsoryFieldsCompleteOnly()) {
+      List<DataElementOperand> missingDataElementOperands =
+          getMissingCompulsoryFields(
+              registration.getDataSet(),
+              registration.getPeriod(),
+              registration.getSource(),
+              registration.getAttributeOptionCombo());
+      if (!missingDataElementOperands.isEmpty()) {
+        String deos =
+            missingDataElementOperands.stream()
+                .map(DataElementOperand::getDisplayName)
+                .collect(Collectors.joining(","));
+        throw new IllegalStateException(
+            "All compulsory data element operands need to be filled: [%s]".formatted(deos));
path: dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/dataset/DefaultCompleteDataSetRegistrationService.java
user: login: david-mackessy
body: - this error message is consistent with how the mature/bulk API for completing data sets works
  - the bulk API returns a report summary with conflicts (no error code)
  - the single/newish API was implemented to not return any response body/summary
    - it doesn't look like we tried (or thought about) keeping similar behaviour (validation, error handling, response etc.) between these APIs for whatever reason
  - if we add an error code in this fix, then behaviour (what little there is) would be inconsistent with the bulk API
- we could change both to return error codes for this particular issue
  - probably best doing that in a separate PR
  - we could improve the single API to return a report also to try to keep things more consistent
  - best not to change behaviour of something else during a bug fix
created_at: 2025-05-22T12:04:19Z
author_association: CONTRIBUTOR

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-05-21T09:24:35Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=dhis2_dhis2-core&pullRequest=21008) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=dhis2_dhis2-core&pullRequest=21008&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=dhis2_dhis2-core&pullRequest=21008&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=dhis2_dhis2-core&pullRequest=21008&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=dhis2_dhis2-core&pullRequest=21008&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=dhis2_dhis2-core&pullRequest=21008&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=dhis2_dhis2-core&pullRequest=21008)


author_association: NONE