diff --git a/.github/workflows/run-api-analytics-tests-clickhouse.yml b/.github/workflows/run-api-analytics-tests-clickhouse.yml new file mode 100644 index 000000000000..e9953a29750c --- /dev/null +++ b/.github/workflows/run-api-analytics-tests-clickhouse.yml @@ -0,0 +1,139 @@ +name: Run analytics api tests [ClickHouse] +env: + # This is to make sure Maven don't timeout fetching dependencies. See: https://github.com/actions/virtual-environments/issues/1499 + MAVEN_OPTS: -Dhttp.keepAlive=false -Dmaven.wagon.http.pool=false -Dmaven.wagon.http.retryHandler.class=standard -Dmaven.wagon.http.retryHandler.count=3 -Dmaven.wagon.httpconnectionManager.ttlSeconds=125 + # PR → quiet (unless ci-verbose label), push/schedule → info, workflow_dispatch → user choice + MAVEN_ARGS: ${{ (inputs.maven_log == 'debug' && '-X') || (github.event_name == 'pull_request' && !contains(github.event.pull_request.labels.*.name, 'ci-verbose') && '-q') || (inputs.maven_log == 'quiet' && '-q') || '' }} + +on: + pull_request: + types: [opened, labeled, synchronize] + schedule: + - cron: "26 1 * * *" # Run at 01:36 AM + workflow_dispatch: + inputs: + maven_log: + description: "Maven log verbosity" + required: false + default: "quiet" + type: choice + options: + - quiet + - info + - debug +concurrency: + group: ${{ github.workflow}}-${{ github.ref }} + cancel-in-progress: true +permissions: + contents: read + pull-requests: write + +jobs: + api-analytics-test-clickhouse: + env: + CORE_IMAGE_NAME: "dhis2/core-dev:local" + PR_NUMBER: ${{ github.event.number }} + DOCKER_CHANNEL: "dhis2/core-pr" + SIERRA_LEONE_DB_PATH: "/tmp/db/sierra-leone" + SIERRA_LEONE_DB_VERSION: "2.39.6" + GITHUB_TOKEN: ${{ secrets.DHIS2_BOT_GITHUB_TOKEN }} + SSH_SIGNING_KEY: ${{ secrets.DHIS2_BOT_SSH_SIGNING_KEY }} + SSH_SIGNING_PASSPHRASE: ${{ secrets.DHIS2_BOT_SSH_SIGNING_PASSPHRASE }} + + runs-on: ubuntu-22.04 + if: contains(github.event.pull_request.labels.*.name, 'run-api-analytics-tests-clickhouse') || (github.event_name == 'workflow_dispatch') + steps: + - uses: actions/checkout@v6 + + - name: Free disk space on runner (Ubuntu) + shell: bash + run: | + # Remove Android and .NET libs (approx. 14GB) + sudo rm -rf /usr/local/lib/android + sudo rm -rf /usr/share/dotnet + + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + java-version: 17 + distribution: temurin + cache: maven + + - name: Build container image + run: | + # only build image for running api tests in this workflow + mvn clean package --threads 2C --batch-mode --no-transfer-progress \ + -DskipTests -Dmaven.test.skip=true --update-snapshots --file dhis-2/pom.xml \ + --projects dhis-web-server --also-make --activate-profiles jibDockerBuild \ + -Djib.to.image=$CORE_IMAGE_NAME + + - name: Get Sierra Leone DB from cache + uses: actions/cache@v5 + id: cache-sierra-leone-db + with: + path: ${{ env.SIERRA_LEONE_DB_PATH }} + key: sierra-leone-db-analytics-${{ env.SIERRA_LEONE_DB_VERSION }} + + - name: Download Sierra Leone DB + if: ${{ steps.cache-sierra-leone-db.outputs.cache-hit != 'true' }} + run: | + mkdir -p ${{ env.SIERRA_LEONE_DB_PATH }} + wget https://databases.dhis2.org/sierra-leone/${{ env.SIERRA_LEONE_DB_VERSION }}/analytics_be/dhis2-db-sierra-leone.sql.gz --no-clobber --directory-prefix ${{ env.SIERRA_LEONE_DB_PATH }} + + - name: Run tests + run: | + cd dhis-2/dhis-test-e2e + source setup_clickhouse_links + DHIS2_IMAGE="$CORE_IMAGE_NAME" \ + DHIS2_E2E_TEST_DUMP_PATH="$SIERRA_LEONE_DB_PATH" \ + CLICKHOUSE_VERSION=24.8 \ + docker compose -f docker-compose.yml -f docker-compose.clickhouse.yml -f docker-compose.e2e.yml \ + -f docker-compose.e2e-analytics.yml -f docker-compose.clickhouse-analytics.yml \ + up --attach test --remove-orphans --exit-code-from test + + # - name: Push surefire reports to repository + # if: always() + # run: | + # cd dhis-2/dhis-test-e2e + # DHIS2_IMAGE="$CORE_IMAGE_NAME" \ + # DHIS2_E2E_TEST_DUMP_PATH="$SIERRA_LEONE_DB_PATH" \ + # CLICKHOUSE_VERSION=24.8 \ + # TEST_TYPE=e2e-analytics \ + # DB_TYPE=clickhouse \ + # docker compose -f docker-compose.yml -f docker-compose.clickhouse.yml -f docker-compose.e2e.yml -f docker-compose.e2e-analytics.yml -f docker-compose.clickhouse-analytics.yml --profile reports run reports-processor + + # - name: Add a comment to the pull request + # if: always() + # uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 + # with: + # issue-number: ${{ github.event.pull_request.number }} + # body: | + # Surefire reports for analytics API tests against ClickHouse have been pushed to the repository. [View reports](https://dhis2.github.io/dhis2-test-reports/reports/?component=core&testType=e2e-analytics) + + - name: Upload logs + if: failure() + run: | + cd dhis-2/dhis-test-e2e + docker compose logs web > ~/logs.txt + + - uses: actions/upload-artifact@v7 + if: failure() + with: + name: "tomcat_logs" + path: "~/logs.txt" + + send-slack-message: + runs-on: ubuntu-22.04 + if: | + always() && + contains(needs.*.result, 'failure') && + github.ref == 'refs/heads/master' + + needs: [api-analytics-test-clickhouse] + steps: + - uses: rtCamp/action-slack-notify@v2 + env: + SLACK_WEBHOOK: ${{ secrets.SLACK_BACKEND_WEBHOOK }} + SLACK_CHANNEL: "team-backend" + SLACK_MESSAGE: "Latest analytics e2e test run against ClickHouse on master failed and needs investigation :detective-duck:. \n Commit message: ${{ github.event.head_commit.message }}" + SLACK_COLOR: "#ff0000" diff --git a/.github/workflows/run-api-analytics-tests-doris.yml b/.github/workflows/run-api-analytics-tests-doris.yml index d7816c278c0a..d4abd9337a96 100644 --- a/.github/workflows/run-api-analytics-tests-doris.yml +++ b/.github/workflows/run-api-analytics-tests-doris.yml @@ -33,6 +33,8 @@ jobs: SIERRA_LEONE_DB_PATH: "/tmp/db/sierra-leone" SIERRA_LEONE_DB_VERSION: "2.39.6" GITHUB_TOKEN: ${{ secrets.DHIS2_BOT_GITHUB_TOKEN }} + SSH_SIGNING_KEY: ${{ secrets.DHIS2_BOT_SSH_SIGNING_KEY }} + SSH_SIGNING_PASSPHRASE: ${{ secrets.DHIS2_BOT_SSH_SIGNING_PASSPHRASE }} runs-on: ubuntu-22.04 if: contains(github.event.pull_request.labels.*.name, 'run-api-analytics-tests-doris') || (github.event_name == 'workflow_dispatch') @@ -85,26 +87,6 @@ jobs: -f docker-compose.e2e-analytics.yml -f docker-compose.doris-analytics.yml \ up --remove-orphans --exit-code-from test - - name: Push surefire reports to repository - if: always() - run: | - cd dhis-2/dhis-test-e2e - DHIS2_IMAGE="$CORE_IMAGE_NAME" \ - DHIS2_E2E_TEST_DUMP_PATH="$SIERRA_LEONE_DB_PATH" \ - DORIS_VERSION=3.0.7 \ - TEST_TYPE=e2e-analytics \ - DB_TYPE=doris \ - docker compose -f docker-compose.yml -f docker-compose.doris.yml -f docker-compose.e2e.yml -f docker-compose.e2e-analytics.yml -f docker-compose.doris-analytics.yml --profile reports run reports-processor - - - name: Add a comment to the pull request - if: always() - uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 - with: - issue-number: ${{ github.event.pull_request.number }} - body: | - Surefire reports for analytics API tests against Doris have been pushed to the repository. [View reports](https://dhis2.github.io/dhis2-test-reports/reports/?component=core&testType=e2e-analytics) - - - name: Upload logs if: failure() run: | @@ -132,4 +114,3 @@ jobs: SLACK_CHANNEL: "team-backend" SLACK_MESSAGE: "Latest analytics e2e test run against Doris on master failed and needs investigation :detective-duck:. \n Commit message: ${{ github.event.head_commit.message }}" SLACK_COLOR: "#ff0000" - \ No newline at end of file diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/data/DimensionalObjectProvider.java b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/data/DimensionalObjectProvider.java index 91a037580fbe..f671c8cf14e5 100644 --- a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/data/DimensionalObjectProvider.java +++ b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/data/DimensionalObjectProvider.java @@ -459,18 +459,50 @@ public List getOrgUnitDimensionUid( * its keywords. * @param userOrgUnits the list of organisation units associated with the current user. * @param expandGroupsAndLevels if true, expands LEVEL-X and OU_GROUP-X to their member org units. - * This is needed for SQL filtering but should be false for metadata generation. + * Explicit org units are kept in the result (union semantics). This is needed for SQL + * filtering but should be false for metadata generation. * @return a list of {@link OrganisationUnit} UIDs. */ public List getOrgUnitDimensionUid( List items, List userOrgUnits, boolean expandGroupsAndLevels) { + return getOrgUnitDimensionUid(items, userOrgUnits, expandGroupsAndLevels, false); + } + + /** + * This method will return a list of {@link OrganisationUnit} UIDs based on the given items and + * user organisation units. + * + * @param items the list of items that might be included into the resulting organisation unit and + * its keywords. + * @param userOrgUnits the list of organisation units associated with the current user. + * @param expandGroupsAndLevels if true, expands LEVEL-X and OU_GROUP-X to their member org units. + * This is needed for SQL filtering but should be false for metadata generation. + * @param orgUnitsAsBoundaries if true and LEVEL-X / OU_GROUP-X selectors are present, explicit + * org units only scope the expansion and are not included in the result (stage.ou dimension + * semantics). If false, explicit org units are kept alongside the expanded members (union + * semantics, used by org unit typed data element filters). Only relevant when + * expandGroupsAndLevels is true. + * @return a list of {@link OrganisationUnit} UIDs. + */ + public List getOrgUnitDimensionUid( + List items, + List userOrgUnits, + boolean expandGroupsAndLevels, + boolean orgUnitsAsBoundaries) { List levels = new ArrayList<>(); List groups = new ArrayList<>(); List ous = getOrgUnitDimensionItems(items, userOrgUnits, IdScheme.UID, levels, groups); - List result = new ArrayList<>(ous.stream().map(DimensionalItemObject::getUid).toList()); + boolean hasLevelsOrGroups = !levels.isEmpty() || !groups.isEmpty(); + + List result = new ArrayList<>(); + + // With boundary semantics, OUs combined with levels / groups only scope the expansion + if (!(expandGroupsAndLevels && orgUnitsAsBoundaries && hasLevelsOrGroups)) { + result.addAll(ous.stream().map(DimensionalItemObject::getUid).toList()); + } if (expandGroupsAndLevels) { List ousList = asTypedList(ous); diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/AbstractJdbcEventAnalyticsManager.java b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/AbstractJdbcEventAnalyticsManager.java index cf3744eadcf9..1bec44447588 100644 --- a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/AbstractJdbcEventAnalyticsManager.java +++ b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/AbstractJdbcEventAnalyticsManager.java @@ -1816,7 +1816,7 @@ public String toSql(QueryItem item, QueryFilter filter, EventQueryParams params) String filterString = item.getValueType() == ValueType.ORGANISATION_UNIT - ? organisationUnitResolver.resolveOrgUnits(filter, params.getUserOrgUnits()) + ? organisationUnitResolver.resolveOrgUnits(filter, params.getUserOrgUnits(), item) : filter.getFilter(); if (IN.equals(filter.getOperator())) { @@ -3140,7 +3140,7 @@ private Condition buildInFilterCondition( // Resolve special org unit keywords like USER_ORGUNIT if applicable String resolvedFilter = filterBuilder.requiresOrgUnitResolution(item) - ? organisationUnitResolver.resolveOrgUnits(filter, params.getUserOrgUnits()) + ? organisationUnitResolver.resolveOrgUnits(filter, params.getUserOrgUnits(), item) : filter.getFilter(); InQueryCteFilter inQueryCteFilter = @@ -3191,7 +3191,7 @@ private String getSqlFilterValue(QueryFilter filter, QueryItem item, EventQueryP // Resolve special org unit keywords like USER_ORGUNIT if applicable String filterValue = filterBuilder.requiresOrgUnitResolution(item) - ? organisationUnitResolver.resolveOrgUnits(filter, params.getUserOrgUnits()) + ? organisationUnitResolver.resolveOrgUnits(filter, params.getUserOrgUnits(), item) : filter.getFilter(); if ("NV".equals(filterValue)) { diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/OrganisationUnitResolver.java b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/OrganisationUnitResolver.java index 492f455fe6bb..bf0b078d3558 100644 --- a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/OrganisationUnitResolver.java +++ b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/OrganisationUnitResolver.java @@ -89,16 +89,38 @@ public class OrganisationUnitResolver { /** * Resolve organisation units like ou:USER_ORGUNIT;USER_ORGUNIT_CHILDREN;LEVEL-XXX;OUGROUP-XXX * into a list of organisation unit dimension uids. Groups and levels are expanded to their member - * org units for SQL filtering purposes. + * org units for SQL filtering purposes. Explicit org units are kept in the result (union + * semantics, used by org unit typed data element filters). * * @param queryFilter the query filter containing the organisation unit filter * @param userOrgUnits the user organisation units * @return the organisation unit dimension uids */ public String resolveOrgUnits(QueryFilter queryFilter, List userOrgUnits) { + return resolveOrgUnits(queryFilter, userOrgUnits, false); + } + + /** + * Resolve organisation units from the given query filter, applying boundary semantics when the + * item is a stage.ou dimension (explicit org units only scope the LEVEL-/OU_GROUP- expansion) and + * union semantics otherwise. + * + * @param queryFilter the query filter containing the organisation unit filter + * @param userOrgUnits the user organisation units + * @param item the query item the filter belongs to + * @return the organisation unit dimension uids + */ + public String resolveOrgUnits( + QueryFilter queryFilter, List userOrgUnits, QueryItem item) { + return resolveOrgUnits(queryFilter, userOrgUnits, isStageOuDimension(item)); + } + + private String resolveOrgUnits( + QueryFilter queryFilter, List userOrgUnits, boolean orgUnitsAsBoundaries) { List filterItem = QueryFilter.getFilterItems(queryFilter.getFilter()); List orgUnitDimensionUid = - dimensionalObjectProducer.getOrgUnitDimensionUid(filterItem, userOrgUnits, true); + dimensionalObjectProducer.getOrgUnitDimensionUid( + filterItem, userOrgUnits, true, orgUnitsAsBoundaries); // Throw E7143 if no valid org units were resolved (mirrors standard ou: dimension behavior) if (orgUnitDimensionUid.isEmpty()) { @@ -120,7 +142,8 @@ public Map getMetadataItemsForOrgUnitDataElements(EventQue for (QueryItem queryItem : params.getItems()) { if (queryItem.getValueType().isOrganisationUnit()) { for (QueryFilter queryFilter : queryItem.getFilters()) { - String resolveOrgUnits = resolveOrgUnits(queryFilter, params.getUserOrgUnits()); + String resolveOrgUnits = + resolveOrgUnits(queryFilter, params.getUserOrgUnits(), queryItem); if (StringUtils.isNotBlank(resolveOrgUnits)) { orgUnitIds.addAll(Arrays.asList(resolveOrgUnits.split(OPTION_SEP))); } @@ -162,7 +185,7 @@ private MetadataItem toMetadataItem(OrganisationUnit orgUnit, EventQueryParams p */ public List resolveOrgUnits(EventQueryParams params, QueryItem item) { return item.getFilters().stream() - .map(queryFilter -> resolveOrgUnits(queryFilter, params.getUserOrgUnits())) + .map(queryFilter -> resolveOrgUnits(queryFilter, params.getUserOrgUnits(), item)) .map(s -> s.split(DimensionConstants.OPTION_SEP)) .flatMap(Arrays::stream) .distinct() @@ -224,9 +247,12 @@ public Map> resolveOrgUnitsGroupedByLevel( public DimensionalItemObject loadOrgUnitDimensionalItem( @Nonnull String dimensionUid, @Nonnull IdScheme idScheme) { if (dimensionUid.startsWith(KEY_LEVEL)) { + String levelToken = substringAfterLast(dimensionUid, SEPARATOR); OrganisationUnitLevel level = - idObjectManager.getObject( - OrganisationUnitLevel.class, idScheme, substringAfterLast(dimensionUid, SEPARATOR)); + StringUtils.isNumeric(levelToken) + ? organisationUnitService.getOrganisationUnitLevelByLevel( + Integer.parseInt(levelToken)) + : idObjectManager.getObject(OrganisationUnitLevel.class, idScheme, levelToken); if (level != null) { BaseDimensionalItemObject dim = new BaseDimensionalItemObject(); diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/QueryItemFilterBuilder.java b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/QueryItemFilterBuilder.java index 70ab12cc0f21..cc9e7e3621ff 100644 --- a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/QueryItemFilterBuilder.java +++ b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/event/data/QueryItemFilterBuilder.java @@ -101,7 +101,7 @@ public String extractFiltersAsSql(QueryItem item, String columnName, EventQueryP boolean needsResolution = requiresOrgUnitResolution(item); String resolvedFilter = needsResolution - ? organisationUnitResolver.resolveOrgUnits(f, params.getUserOrgUnits()) + ? organisationUnitResolver.resolveOrgUnits(f, params.getUserOrgUnits(), item) : f.getFilter(); return buildFilterCondition(f, item, columnName, resolvedFilter); @@ -228,7 +228,7 @@ public String extractNonNvFiltersAsSql( boolean needsResolution = requiresOrgUnitResolution(item); String resolvedFilter = needsResolution - ? organisationUnitResolver.resolveOrgUnits(f, params.getUserOrgUnits()) + ? organisationUnitResolver.resolveOrgUnits(f, params.getUserOrgUnits(), item) : f.getFilter(); // For IN operator with mixed values, only include non-NV values diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/tracker/MetadataItemsHandler.java b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/tracker/MetadataItemsHandler.java index e2f518cf5ff3..fa3d9a5539e8 100644 --- a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/tracker/MetadataItemsHandler.java +++ b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/tracker/MetadataItemsHandler.java @@ -33,6 +33,8 @@ import static java.util.Optional.empty; import static org.apache.commons.collections4.CollectionUtils.isNotEmpty; import static org.apache.commons.lang3.StringUtils.trimToEmpty; +import static org.hisp.dhis.analytics.AnalyticsConstants.KEY_LEVEL; +import static org.hisp.dhis.analytics.AnalyticsConstants.KEY_ORGUNIT_GROUP; import static org.hisp.dhis.analytics.AnalyticsMetaDataKey.DIMENSIONS; import static org.hisp.dhis.analytics.AnalyticsMetaDataKey.ITEMS; import static org.hisp.dhis.analytics.AnalyticsMetaDataKey.ORG_UNIT_HIERARCHY; @@ -55,6 +57,7 @@ import static org.hisp.dhis.organisationunit.OrganisationUnit.getParentNameGraphMap; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; import java.util.HashMap; import java.util.LinkedHashSet; @@ -413,7 +416,9 @@ private void addItemsAndFiltersMetadataForNonQuery( /** * Adds metadata entries for organisation units resolved from query item filters (including * keywords like USER_ORGUNIT). This is needed for aggregate endpoints where query items are used - * as dimensions (e.g. stage.ou). + * as dimensions (e.g. stage.ou). For stage.ou dimensions, levels and groups are expanded to their + * member org units so each dimension item gets a metadata entry, while explicit org units + * combined with levels/groups act as boundaries and are excluded. */ private void addResolvedOrgUnitMetadata( Map metadataItemMap, @@ -425,7 +430,9 @@ private void addResolvedOrgUnitMetadata( } List resolvedOrgUnits = - organisationUnitResolver.resolveOrgUnitsForMetadata(params, item); + isStageOuDimension(item) + ? organisationUnitResolver.resolveOrgUnits(params, item) + : organisationUnitResolver.resolveOrgUnitsForMetadata(params, item); for (String uid : resolvedOrgUnits) { DimensionalItemObject itemObject = organisationUnitResolver.loadOrgUnitDimensionalItem(uid, IdScheme.UID); @@ -437,6 +444,34 @@ private void addResolvedOrgUnitMetadata( params.getDisplayProperty()); } } + + addLevelAndGroupMetadata(metadataItemMap, params, includeDetails, item); + } + + /** + * Adds metadata entries for LEVEL- and OU_GROUP- selectors present in the filters of the given + * org unit query item, keyed by the level/group UID (e.g. "tTUf91fCytl": {"name": "Chiefdom"}). + */ + private void addLevelAndGroupMetadata( + Map metadataItemMap, + EventQueryParams params, + boolean includeDetails, + QueryItem item) { + for (QueryFilter filter : item.getFilters()) { + for (String filterValue : QueryFilter.getFilterItems(filter.getFilter())) { + if (isLevelOrGroup(filterValue)) { + DimensionalItemObject itemObject = + organisationUnitResolver.loadOrgUnitDimensionalItem(filterValue, IdScheme.UID); + if (itemObject != null) { + addItemToMetadata( + metadataItemMap, + new QueryItem(itemObject), + includeDetails, + params.getDisplayProperty()); + } + } + } + } } /** @@ -1004,7 +1039,13 @@ private void addOrgUnitDimensionFilter( for (QueryFilter filter : filters) { String[] filterValues = trimToEmpty(filter.getFilter()).split(OPTION_SEP); + boolean hasLevelsOrGroups = Arrays.stream(filterValues).anyMatch(this::isLevelOrGroup); for (String filterValue : filterValues) { + // When levels / groups are present, plain org units act as boundaries for the + // expansion and are not dimension items + if (hasLevelsOrGroups && !isLevelOrGroup(filterValue)) { + continue; + } DimensionalItemObject itemObject = organisationUnitResolver.loadOrgUnitDimensionalItem(filterValue, IdScheme.UID); if (itemObject != null) { @@ -1017,4 +1058,8 @@ private void addOrgUnitDimensionFilter( } } } + + private boolean isLevelOrGroup(String filterValue) { + return filterValue.startsWith(KEY_LEVEL) || filterValue.startsWith(KEY_ORGUNIT_GROUP); + } } diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/data/DimensionalObjectProviderTest.java b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/data/DimensionalObjectProviderTest.java index 6dd523504ef5..810bb784a7b4 100644 --- a/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/data/DimensionalObjectProviderTest.java +++ b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/data/DimensionalObjectProviderTest.java @@ -357,6 +357,110 @@ void testGetOrgUnitDimensionWithWithProgram() { assertOrgUnits(ou2, refOu2); } + @Test + void testGetOrgUnitDimensionUidWithLevelTreatsExplicitOrgUnitsAsBoundaries() { + // Given + OrganisationUnit boundary = createOrganisationUnit('A'); + OrganisationUnit level2Ou1 = createOrganisationUnit('B'); + OrganisationUnit level2Ou2 = createOrganisationUnit('C'); + + when(idObjectManager.getObject(OrganisationUnit.class, UID, boundary.getUid())) + .thenReturn(boundary); + when(organisationUnitService.getOrganisationUnitLevelByLevelOrUid("2")).thenReturn(2); + when(organisationUnitService.getOrganisationUnitsAtLevels(List.of(2), List.of(boundary))) + .thenReturn(new ArrayList<>(asList(level2Ou1, level2Ou2))); + + List itemsUid = List.of("LEVEL-2", boundary.getUid()); + + // When + List result = target.getOrgUnitDimensionUid(itemsUid, List.of(), true, true); + + // Then + assertEquals(List.of(level2Ou1.getUid(), level2Ou2.getUid()), result); + } + + @Test + void testGetOrgUnitDimensionUidWithLevelKeepsExplicitOrgUnitsWithUnionSemantics() { + // Given + OrganisationUnit ou = createOrganisationUnit('A'); + OrganisationUnit level2Ou1 = createOrganisationUnit('B'); + OrganisationUnit level2Ou2 = createOrganisationUnit('C'); + + when(idObjectManager.getObject(OrganisationUnit.class, UID, ou.getUid())).thenReturn(ou); + when(organisationUnitService.getOrganisationUnitLevelByLevelOrUid("2")).thenReturn(2); + when(organisationUnitService.getOrganisationUnitsAtLevels(List.of(2), List.of(ou))) + .thenReturn(new ArrayList<>(asList(level2Ou1, level2Ou2))); + + List itemsUid = List.of("LEVEL-2", ou.getUid()); + + // When + List result = target.getOrgUnitDimensionUid(itemsUid, List.of(), true, false); + + // Then + assertEquals(List.of(ou.getUid(), level2Ou1.getUid(), level2Ou2.getUid()), result); + } + + @Test + void testGetOrgUnitDimensionUidWithUnionSemanticsIsNotEmptyWhenLevelYieldsNoMembers() { + // Given an org unit below the requested level, so the level expansion yields nothing + OrganisationUnit ou = createOrganisationUnit('A'); + + when(idObjectManager.getObject(OrganisationUnit.class, UID, ou.getUid())).thenReturn(ou); + when(organisationUnitService.getOrganisationUnitLevelByLevelOrUid("1")).thenReturn(1); + when(organisationUnitService.getOrganisationUnitsAtLevels(List.of(1), List.of(ou))) + .thenReturn(new ArrayList<>()); + + List itemsUid = List.of("LEVEL-1", ou.getUid()); + + // When + List result = target.getOrgUnitDimensionUid(itemsUid, List.of(), true, false); + + // Then + assertEquals(List.of(ou.getUid()), result); + } + + @Test + void testGetOrgUnitDimensionUidWithGroupTreatsExplicitOrgUnitsAsBoundaries() { + // Given + OrganisationUnitGroup group = createOrganisationUnitGroup('A'); + OrganisationUnit boundary = createOrganisationUnit('A'); + OrganisationUnit member1 = createOrganisationUnit('B'); + OrganisationUnit member2 = createOrganisationUnit('C'); + + when(idObjectManager.getObject(OrganisationUnit.class, UID, boundary.getUid())) + .thenReturn(boundary); + when(idObjectManager.getObject(OrganisationUnitGroup.class, UID, group.getUid())) + .thenReturn(group); + when(organisationUnitService.getOrganisationUnits(List.of(group), List.of(boundary))) + .thenReturn(new ArrayList<>(asList(member1, member2))); + + List itemsUid = List.of("OU_GROUP-" + group.getUid(), boundary.getUid()); + + // When + List result = target.getOrgUnitDimensionUid(itemsUid, List.of(), true, true); + + // Then + assertEquals(List.of(member1.getUid(), member2.getUid()), result); + } + + @Test + void testGetOrgUnitDimensionUidWithoutLevelsOrGroupsReturnsExplicitOrgUnits() { + // Given + OrganisationUnit ou1 = createOrganisationUnit('A'); + OrganisationUnit ou2 = createOrganisationUnit('B'); + + when(idObjectManager.getObject(OrganisationUnit.class, UID, ou1.getUid())).thenReturn(ou1); + when(idObjectManager.getObject(OrganisationUnit.class, UID, ou2.getUid())).thenReturn(ou2); + + List itemsUid = List.of(ou1.getUid(), ou2.getUid()); + + // When + List result = target.getOrgUnitDimensionUid(itemsUid, List.of(), true, true); + + // Then + assertEquals(List.of(ou1.getUid(), ou2.getUid()), result); + } + @Test void testGetDimensionWhenOrgUnitsAreNotFound() { List nonExistingItemsUid = List.of("LEVEL-Achv9EO3hR1", "OU_GROUP-Blhv9EO3hR1"); diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/AbstractJdbcEventAnalyticsManagerTest.java b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/AbstractJdbcEventAnalyticsManagerTest.java index 89df3a4feae1..c7d1326f5158 100644 --- a/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/AbstractJdbcEventAnalyticsManagerTest.java +++ b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/AbstractJdbcEventAnalyticsManagerTest.java @@ -1171,7 +1171,8 @@ void testItemsInFilterAreQuotedForOrganisationUnit() { new EventQueryParams.Builder().withStartDate(new Date()).withEndDate(new Date()).build(); when(queryItem.getItemName()).thenReturn("anyItem"); when(queryItem.getValueType()).thenReturn(ValueType.ORGANISATION_UNIT); - when(organisationUnitResolver.resolveOrgUnits(any(QueryFilter.class), anyList())) + when(organisationUnitResolver.resolveOrgUnits( + any(QueryFilter.class), anyList(), any(QueryItem.class))) .thenReturn("A;B;C"); // When @@ -1197,7 +1198,8 @@ void testInFilterForEnrollmentsAggregate() { .withEndpointAction(AGGREGATE) .withEndpointItem(ENROLLMENT) .build(); - when(organisationUnitResolver.resolveOrgUnits(any(QueryFilter.class), anyList())) + when(organisationUnitResolver.resolveOrgUnits( + any(QueryFilter.class), anyList(), any(QueryItem.class))) .thenReturn("A;B;C"); // When @@ -1292,7 +1294,8 @@ void testToSqlWithProgramStageOrgUnit() { .withEndDate(new Date()) .build(); - when(organisationUnitResolver.resolveOrgUnits(any(QueryFilter.class), anyList())) + when(organisationUnitResolver.resolveOrgUnits( + any(QueryFilter.class), anyList(), any(QueryItem.class))) .thenReturn("ouA;ouB"); String sql = eventSubject.toSql(queryItem, filter, params).trim(); diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/EnrollmentAnalyticsManagerCteTest.java b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/EnrollmentAnalyticsManagerCteTest.java index df59ca254b47..504c8bab8371 100644 --- a/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/EnrollmentAnalyticsManagerCteTest.java +++ b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/EnrollmentAnalyticsManagerCteTest.java @@ -49,6 +49,7 @@ import static org.hisp.dhis.test.TestBase.createPeriodDimensions; import static org.hisp.dhis.test.TestBase.createProgramIndicator; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; @@ -81,6 +82,7 @@ import org.hisp.dhis.common.QueryOperator; import org.hisp.dhis.common.RepeatableStageParams; import org.hisp.dhis.common.ValueType; +import org.hisp.dhis.dataelement.DataElement; import org.hisp.dhis.dataelement.DataElementService; import org.hisp.dhis.db.sql.AnalyticsSqlBuilder; import org.hisp.dhis.db.sql.ClickHouseAnalyticsSqlBuilder; @@ -336,7 +338,10 @@ void verifyAggregateEnrollmentWithStageDateDimensionGeneratesValidSql() { verify(jdbcTemplate).queryForRowSet(sql.capture()); String generatedSql = sql.getValue(); - + String baseCteSql = + generatedSql.substring( + generatedSql.indexOf("enrollment_aggr_base as ("), + generatedSql.indexOf("select count(eb.enrollment) as value")); // The SQL should contain a per-stage filter CTE assertThat(generatedSql, containsString("latest_events_" + programStage.getUid())); @@ -347,6 +352,62 @@ void verifyAggregateEnrollmentWithStageDateDimensionGeneratesValidSql() { // The filter CTE should use the ev_occurreddate alias for the date column assertThat(generatedSql, containsString("ev_occurreddate")); + // Stage event-date periods must not leak into the base enrollment-date filter + assertThat(baseCteSql, not(containsString("enrollmentdate >="))); + } + + @Test + void verifyAggregateEnrollmentStageOrgUnitFilterStaysInFilterCte() { + DataElement orgUnitDataElement = + org.hisp.dhis.test.TestBase.createDataElement( + 'O', ValueType.ORGANISATION_UNIT, AggregationType.NONE); + orgUnitDataElement.setUid("n1rtSHYf6O6"); + + QueryItem queryItem = + new QueryItem( + orgUnitDataElement, + programA, + null, + ValueType.ORGANISATION_UNIT, + orgUnitDataElement.getAggregationType(), + null); + queryItem.setProgram(programA); + queryItem.setProgramStage(programStage); + queryItem.addFilter(new QueryFilter(IN, "ImspTQPwCqd")); + + when(organisationUnitResolver.resolveOrgUnits( + any(QueryFilter.class), anyList(), any(QueryItem.class))) + .thenReturn("ImspTQPwCqd"); + + EventQueryParams.Builder params = createRequestParamsBuilder(); + params.withEndpointAction(AGGREGATE); + params.addItemFilter(queryItem); + + ListGrid grid = new ListGrid(); + grid.addHeader(new GridHeader("value", "Value", ValueType.NUMBER, false, false)); + + subject.getEnrollments(params.build(), grid, 10000); + verify(jdbcTemplate).queryForRowSet(sql.capture()); + + String generatedSql = noEof(sql.getValue()); + String baseCteSql = + generatedSql.substring( + generatedSql.indexOf("enrollment_aggr_base as ("), + generatedSql.indexOf("select count(eb.enrollment) as value")); + + assertThat( + generatedSql, + containsString( + noEof( + """ + latest_events_%s as ( + select enrollment, ev_n1rtSHYf6O6 + from + """ + .formatted(programStage.getUid())))); + assertThat(generatedSql, containsString("\"n1rtSHYf6O6\" in ('ImspTQPwCqd')")); + assertThat(baseCteSql, containsString("inner join latest_events_" + programStage.getUid())); + assertThat(baseCteSql, not(containsString("and \"n1rtSHYf6O6\" in ('ImspTQPwCqd')"))); } @Test diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/OrganisationUnitResolverTest.java b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/OrganisationUnitResolverTest.java new file mode 100644 index 000000000000..9798293c28a7 --- /dev/null +++ b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/OrganisationUnitResolverTest.java @@ -0,0 +1,226 @@ +/* + * Copyright (c) 2004-2026, 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.analytics.event.data; + +import static org.hisp.dhis.test.TestBase.createDataElement; +import static org.hisp.dhis.test.TestBase.createProgram; +import static org.hisp.dhis.test.TestBase.createProgramStage; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; + +import java.util.List; +import org.hisp.dhis.analytics.AggregationType; +import org.hisp.dhis.analytics.data.DimensionalObjectProvider; +import org.hisp.dhis.analytics.event.EventQueryParams; +import org.hisp.dhis.common.BaseDimensionalItemObject; +import org.hisp.dhis.common.DimensionalItemObject; +import org.hisp.dhis.common.IdScheme; +import org.hisp.dhis.common.IdentifiableObjectManager; +import org.hisp.dhis.common.QueryFilter; +import org.hisp.dhis.common.QueryItem; +import org.hisp.dhis.common.QueryOperator; +import org.hisp.dhis.common.ValueType; +import org.hisp.dhis.dataelement.DataElement; +import org.hisp.dhis.db.sql.AnalyticsSqlBuilder; +import org.hisp.dhis.db.sql.PostgreSqlAnalyticsSqlBuilder; +import org.hisp.dhis.organisationunit.OrganisationUnit; +import org.hisp.dhis.organisationunit.OrganisationUnitLevel; +import org.hisp.dhis.organisationunit.OrganisationUnitService; +import org.hisp.dhis.program.Program; +import org.hisp.dhis.program.ProgramStage; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +/** Unit tests for {@link OrganisationUnitResolver}. */ +@ExtendWith(MockitoExtension.class) +class OrganisationUnitResolverTest { + + @Mock private DimensionalObjectProvider dimensionalObjectProducer; + + @Mock private OrganisationUnitService organisationUnitService; + + @Mock private IdentifiableObjectManager idObjectManager; + + @Mock private AnalyticsSqlBuilder sqlBuilder; + + @InjectMocks private OrganisationUnitResolver resolver; + + @Test + void buildStageOuCteContextUsesResolvedLevelOrgUnitsAndExcludesBoundary() { + // Given + OrganisationUnit resolvedDistrict = new OrganisationUnit("Bo"); + resolvedDistrict.setUid("O6uvpzGd5pu"); + resolvedDistrict.setPath("/ImspTQPwCqd/O6uvpzGd5pu"); + + Program program = createProgram('A'); + ProgramStage programStage = createProgramStage('S', program); + QueryItem stageOuItem = + new QueryItem( + new BaseDimensionalItemObject("ou"), + program, + null, + ValueType.ORGANISATION_UNIT, + AggregationType.NONE, + null); + stageOuItem.setProgramStage(programStage); + stageOuItem.addFilter(new QueryFilter(QueryOperator.IN, "LEVEL-wjP19dkFeIk;ImspTQPwCqd")); + + OrganisationUnitResolver resolverWithSqlBuilder = + new OrganisationUnitResolver( + dimensionalObjectProducer, + organisationUnitService, + idObjectManager, + new PostgreSqlAnalyticsSqlBuilder()); + + EventQueryParams params = new EventQueryParams.Builder().withUserOrgUnits(List.of()).build(); + + when(dimensionalObjectProducer.getOrgUnitDimensionUid( + List.of("LEVEL-wjP19dkFeIk", "ImspTQPwCqd"), List.of(), true, true)) + .thenReturn(List.of(resolvedDistrict.getUid())); + when(organisationUnitService.getOrganisationUnitsByUid(List.of(resolvedDistrict.getUid()))) + .thenReturn(List.of(resolvedDistrict)); + + // When + OrganisationUnitResolver.StageOuCteContext context = + resolverWithSqlBuilder.buildStageOuCteContext(stageOuItem, params); + + // Then + assertEquals("\"uidlevel2\"", context.valueColumn()); + assertEquals("\"uidlevel2\" in ('O6uvpzGd5pu')", context.filterCondition()); + assertFalse(context.filterCondition().contains("ImspTQPwCqd")); + assertFalse(context.filterCondition().contains("uidlevel1")); + assertTrue(context.additionalSelectColumns().contains("\"ouname\" as ev_ouname")); + } + + @Test + void resolveOrgUnitsAppliesUnionSemanticsForOrgUnitDataElementFilters() { + // Given + DataElement orgUnitDataElement = createDataElement('B'); + orgUnitDataElement.setValueType(ValueType.ORGANISATION_UNIT); + QueryItem dataElementItem = + new QueryItem( + orgUnitDataElement, + null, + orgUnitDataElement.getValueType(), + orgUnitDataElement.getAggregationType(), + null); + QueryFilter filter = new QueryFilter(QueryOperator.IN, "OI0BQUurVFS;LEVEL-H1KlN4QIauv"); + + when(dimensionalObjectProducer.getOrgUnitDimensionUid( + List.of("OI0BQUurVFS", "LEVEL-H1KlN4QIauv"), List.of(), true, false)) + .thenReturn(List.of("OI0BQUurVFS")); + + // When + String result = resolver.resolveOrgUnits(filter, List.of(), dataElementItem); + + // Then + assertEquals("OI0BQUurVFS", result); + } + + @Test + void resolveOrgUnitsAppliesBoundarySemanticsForStageOuDimension() { + // Given + Program program = createProgram('A'); + ProgramStage programStage = createProgramStage('S', program); + QueryItem stageOuItem = + new QueryItem( + new BaseDimensionalItemObject("ou"), + program, + null, + ValueType.ORGANISATION_UNIT, + AggregationType.NONE, + null); + stageOuItem.setProgramStage(programStage); + QueryFilter filter = new QueryFilter(QueryOperator.IN, "LEVEL-wjP19dkFeIk;ImspTQPwCqd"); + + when(dimensionalObjectProducer.getOrgUnitDimensionUid( + List.of("LEVEL-wjP19dkFeIk", "ImspTQPwCqd"), List.of(), true, true)) + .thenReturn(List.of("O6uvpzGd5pu")); + + // When + String result = resolver.resolveOrgUnits(filter, List.of(), stageOuItem); + + // Then + assertEquals("O6uvpzGd5pu", result); + } + + @Test + void loadOrgUnitDimensionalItemResolvesNumericLevel() { + // Given + OrganisationUnitLevel level = new OrganisationUnitLevel(3, "Chiefdom"); + level.setUid("tTUf91fCytl"); + when(organisationUnitService.getOrganisationUnitLevelByLevel(3)).thenReturn(level); + + // When + DimensionalItemObject result = resolver.loadOrgUnitDimensionalItem("LEVEL-3", IdScheme.UID); + + // Then + assertNotNull(result); + assertEquals("tTUf91fCytl", result.getUid()); + assertEquals("Chiefdom", result.getName()); + } + + @Test + void loadOrgUnitDimensionalItemResolvesLevelByUid() { + // Given + OrganisationUnitLevel level = new OrganisationUnitLevel(3, "Chiefdom"); + level.setUid("tTUf91fCytl"); + when(idObjectManager.getObject(OrganisationUnitLevel.class, IdScheme.UID, "tTUf91fCytl")) + .thenReturn(level); + + // When + DimensionalItemObject result = + resolver.loadOrgUnitDimensionalItem("LEVEL-tTUf91fCytl", IdScheme.UID); + + // Then + assertNotNull(result); + assertEquals("tTUf91fCytl", result.getUid()); + assertEquals("Chiefdom", result.getName()); + } + + @Test + void loadOrgUnitDimensionalItemReturnsNullForUnknownLevel() { + // Given + when(organisationUnitService.getOrganisationUnitLevelByLevel(9)).thenReturn(null); + + // When + DimensionalItemObject result = resolver.loadOrgUnitDimensionalItem("LEVEL-9", IdScheme.UID); + + // Then + assertNull(result); + } +} diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/QueryItemFilterBuilderTest.java b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/QueryItemFilterBuilderTest.java new file mode 100644 index 000000000000..ae8d603f4426 --- /dev/null +++ b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/event/data/QueryItemFilterBuilderTest.java @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2004-2026, 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.analytics.event.data; + +import static org.hisp.dhis.test.TestBase.createDataElement; +import static org.hisp.dhis.test.TestBase.createOrganisationUnit; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import org.hisp.dhis.analytics.event.EventQueryParams; +import org.hisp.dhis.common.QueryFilter; +import org.hisp.dhis.common.QueryItem; +import org.hisp.dhis.common.QueryOperator; +import org.hisp.dhis.common.ValueType; +import org.hisp.dhis.dataelement.DataElement; +import org.hisp.dhis.db.sql.PostgreSqlAnalyticsSqlBuilder; +import org.hisp.dhis.organisationunit.OrganisationUnit; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class QueryItemFilterBuilderTest { + + @Mock private OrganisationUnitResolver organisationUnitResolver; + + private QueryItemFilterBuilder target; + + @BeforeEach + void setUp() { + target = + new QueryItemFilterBuilder(organisationUnitResolver, new PostgreSqlAnalyticsSqlBuilder()); + } + + @Test + void extractFiltersAsSqlKeepsExplicitOrgUnitsForOrgUnitDataElementFilters() { + // Given + OrganisationUnit userOrgUnit = createOrganisationUnit('A'); + DataElement orgUnitDataElement = createDataElement('B'); + orgUnitDataElement.setValueType(ValueType.ORGANISATION_UNIT); + + QueryItem item = + new QueryItem( + orgUnitDataElement, + null, + orgUnitDataElement.getValueType(), + orgUnitDataElement.getAggregationType(), + null); + QueryFilter filter = new QueryFilter(QueryOperator.IN, "OU_GROUP-tDZVQ1WtwpA;ImspTQPwCqd"); + item.addFilter(filter); + + EventQueryParams params = + new EventQueryParams.Builder().withUserOrgUnits(List.of(userOrgUnit)).build(); + + // Union semantics: group members plus the explicit org unit + when(organisationUnitResolver.resolveOrgUnits(same(filter), anyList(), same(item))) + .thenReturn("ImspTQPwCqd;O6uvpzGd5pu;fdc6uOvgoji"); + + // When + String sql = target.extractFiltersAsSql(item, "event_ou", params); + + // Then + assertEquals("event_ou in ('ImspTQPwCqd','O6uvpzGd5pu','fdc6uOvgoji')", sql); + verify(organisationUnitResolver).resolveOrgUnits(filter, List.of(userOrgUnit), item); + } +} diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/tracker/MetadataItemsHandlerTest.java b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/tracker/MetadataItemsHandlerTest.java index a976230d65bf..af251aa7b10d 100644 --- a/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/tracker/MetadataItemsHandlerTest.java +++ b/dhis-2/dhis-services/dhis-service-analytics/src/test/java/org/hisp/dhis/analytics/tracker/MetadataItemsHandlerTest.java @@ -56,6 +56,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -626,6 +627,67 @@ void shouldAddMetadataItemsForQueryWithGridData() { assertTrue(grid.getMetaData().containsKey(DIMENSIONS.getKey())); } + @Test + @DisplayName( + "should not add boundary org unit to metadata items when stage ou filter has levels") + void shouldNotAddBoundaryOrgUnitToItemsWhenStageOuFilterHasLevels() { + // Given + Grid grid = new ListGrid(); + grid.addRow(); + grid.addValue("value1"); + + ProgramStage programStage = createProgramStage('S', programA); + programStage.setUid("A03MvHHogjR"); + + OrganisationUnit boundary = createOrganisationUnit('C'); + String levelUid = "tTUf91fCytl"; + + BaseDimensionalItemObject levelItem = new BaseDimensionalItemObject(); + levelItem.setUid(levelUid); + levelItem.setName("Chiefdom"); + + QueryItem stageOuItem = + new QueryItem( + new BaseDimensionalItemObject("ou"), + programA, + null, + ValueType.ORGANISATION_UNIT, + AggregationType.NONE, + null) + .withCustomHeader(AnalyticsCustomHeader.forOrgUnit(programStage)); + stageOuItem.setProgramStage(programStage); + stageOuItem.addFilter( + new QueryFilter(QueryOperator.IN, "LEVEL-" + levelUid + ";" + boundary.getUid())); + + EventQueryParams params = + new EventQueryParams.Builder() + .withProgram(programA) + .withSkipMeta(false) + .withEndpointAction(QUERY) + .withOrganisationUnits(List.of(orgUnitA)) + .withPeriods(createPeriodDimensions("2023Q1"), "quarterly") + .addItem(stageOuItem) + .build(); + + when(userService.getUserByUsername(anyString())).thenReturn(null); + when(organisationUnitResolver.getMetadataItemsForOrgUnitDataElements(any())) + .thenReturn(Map.of()); + when(organisationUnitResolver.loadOrgUnitDimensionalItem("LEVEL-" + levelUid, IdScheme.UID)) + .thenReturn(levelItem); + + // When + metadataItemsHandler.addMetadata(grid, params, List.of()); + + // Then + @SuppressWarnings("unchecked") + Map items = (Map) grid.getMetaData().get(ITEMS.getKey()); + assertNotNull(items); + assertTrue(items.containsKey(levelUid)); + assertFalse(items.containsKey(boundary.getUid())); + verify(organisationUnitResolver, never()) + .loadOrgUnitDimensionalItem(boundary.getUid(), IdScheme.UID); + } + @Test @DisplayName("should add keywords to metadata items") void shouldAddKeywordsToMetadataItems() { @@ -936,9 +998,6 @@ void shouldIncludeResolvedOrgUnitItemForStageOuAggregateDimension() { when(organisationUnitResolver.resolveOrgUnits( any(EventQueryParams.class), any(QueryItem.class))) .thenReturn(List.of(orgUnitA.getUid())); - when(organisationUnitResolver.resolveOrgUnitsForMetadata( - any(EventQueryParams.class), any(QueryItem.class))) - .thenReturn(List.of(orgUnitA.getUid())); when(organisationUnitResolver.loadOrgUnitDimensionalItem(orgUnitA.getUid(), IdScheme.UID)) .thenReturn(orgUnitA); @@ -956,6 +1015,233 @@ void shouldIncludeResolvedOrgUnitItemForStageOuAggregateDimension() { MetadataItem orgUnitItem = (MetadataItem) items.get(orgUnitA.getUid()); assertEquals(orgUnitA.getDisplayName(), orgUnitItem.getName()); } + + @Test + @DisplayName( + "should include level-resolved org units and level name but not the boundary for stage ou aggregate dimension") + void shouldIncludeLevelResolvedOrgUnitsWithoutBoundaryForStageOuAggregateDimension() { + // Given + Grid grid = new ListGrid(); + + ProgramStage programStage = createProgramStage('S', programA); + programStage.setUid("A03MvHHogjR"); + programStage.setName("Birth"); + + OrganisationUnit boundary = createOrganisationUnit('C'); + String levelUid = "tTUf91fCytl"; + + BaseDimensionalItemObject levelItem = new BaseDimensionalItemObject(); + levelItem.setUid(levelUid); + levelItem.setName("Chiefdom"); + + QueryItem stageOuItem = + new QueryItem( + new BaseDimensionalItemObject("ou"), + programA, + null, + ValueType.ORGANISATION_UNIT, + AggregationType.NONE, + null) + .withCustomHeader(AnalyticsCustomHeader.forOrgUnit(programStage)); + stageOuItem.setProgramStage(programStage); + stageOuItem.addFilter( + new QueryFilter(QueryOperator.IN, "LEVEL-" + levelUid + ";" + boundary.getUid())); + + EventQueryParams params = + new EventQueryParams.Builder() + .withProgram(programA) + .withSkipMeta(false) + .withEndpointAction(AGGREGATE) + .withOrganisationUnits(List.of(orgUnitA)) + .withUserOrgUnits(List.of(orgUnitA)) + .withPeriods(createPeriodDimensions("2023Q1"), "quarterly") + .addItem(stageOuItem) + .build(); + + when(userService.getUserByUsername(anyString())).thenReturn(null); + when(organisationUnitResolver.resolveOrgUnits( + any(EventQueryParams.class), any(QueryItem.class))) + .thenReturn(List.of(orgUnitA.getUid(), orgUnitB.getUid())); + when(organisationUnitResolver.loadOrgUnitDimensionalItem(orgUnitA.getUid(), IdScheme.UID)) + .thenReturn(orgUnitA); + when(organisationUnitResolver.loadOrgUnitDimensionalItem(orgUnitB.getUid(), IdScheme.UID)) + .thenReturn(orgUnitB); + when(organisationUnitResolver.loadOrgUnitDimensionalItem("LEVEL-" + levelUid, IdScheme.UID)) + .thenReturn(levelItem); + + // When + metadataItemsHandler.addMetadata(grid, params, List.of()); + + // Then + @SuppressWarnings("unchecked") + Map items = (Map) grid.getMetaData().get(ITEMS.getKey()); + assertNotNull(items); + + assertTrue(items.containsKey("A03MvHHogjR.ou")); + assertTrue(items.containsKey(orgUnitA.getUid())); + assertTrue(items.containsKey(orgUnitB.getUid())); + assertFalse(items.containsKey(boundary.getUid())); + + MetadataItem levelMetadataItem = (MetadataItem) items.get(levelUid); + assertNotNull(levelMetadataItem); + assertEquals("Chiefdom", levelMetadataItem.getName()); + + @SuppressWarnings("unchecked") + Map> dimensions = + (Map>) grid.getMetaData().get(DIMENSIONS.getKey()); + assertNotNull(dimensions); + assertEquals(List.of(orgUnitA.getUid(), orgUnitB.getUid()), dimensions.get("A03MvHHogjR.ou")); + assertFalse(dimensions.get("A03MvHHogjR.ou").contains(boundary.getUid())); + } + + @Test + @DisplayName("should include numeric level name keyed by UID for stage ou aggregate dimension") + void shouldIncludeNumericLevelNameByUidForStageOuAggregateDimension() { + // Given + Grid grid = new ListGrid(); + + ProgramStage programStage = createProgramStage('S', programA); + programStage.setUid("A03MvHHogjR"); + programStage.setName("Birth"); + + OrganisationUnit boundary = createOrganisationUnit('C'); + String levelUid = "tTUf91fCytl"; + + BaseDimensionalItemObject levelItem = new BaseDimensionalItemObject(); + levelItem.setUid(levelUid); + levelItem.setName("Chiefdom"); + + QueryItem stageOuItem = + new QueryItem( + new BaseDimensionalItemObject("ou"), + programA, + null, + ValueType.ORGANISATION_UNIT, + AggregationType.NONE, + null) + .withCustomHeader(AnalyticsCustomHeader.forOrgUnit(programStage)); + stageOuItem.setProgramStage(programStage); + stageOuItem.addFilter(new QueryFilter(QueryOperator.IN, "LEVEL-3;" + boundary.getUid())); + + EventQueryParams params = + new EventQueryParams.Builder() + .withProgram(programA) + .withSkipMeta(false) + .withEndpointAction(AGGREGATE) + .withOrganisationUnits(List.of(orgUnitA)) + .withUserOrgUnits(List.of(orgUnitA)) + .withPeriods(createPeriodDimensions("2023Q1"), "quarterly") + .addItem(stageOuItem) + .build(); + + when(userService.getUserByUsername(anyString())).thenReturn(null); + when(organisationUnitResolver.resolveOrgUnits( + any(EventQueryParams.class), any(QueryItem.class))) + .thenReturn(List.of(orgUnitA.getUid())); + when(organisationUnitResolver.loadOrgUnitDimensionalItem(orgUnitA.getUid(), IdScheme.UID)) + .thenReturn(orgUnitA); + when(organisationUnitResolver.loadOrgUnitDimensionalItem("LEVEL-3", IdScheme.UID)) + .thenReturn(levelItem); + + // When + metadataItemsHandler.addMetadata(grid, params, List.of()); + + // Then + @SuppressWarnings("unchecked") + Map items = (Map) grid.getMetaData().get(ITEMS.getKey()); + assertNotNull(items); + assertTrue(items.containsKey(orgUnitA.getUid())); + assertFalse(items.containsKey(boundary.getUid())); + assertFalse(items.containsKey("3")); + + MetadataItem levelMetadataItem = (MetadataItem) items.get(levelUid); + assertNotNull(levelMetadataItem); + assertEquals("Chiefdom", levelMetadataItem.getName()); + + @SuppressWarnings("unchecked") + Map> dimensions = + (Map>) grid.getMetaData().get(DIMENSIONS.getKey()); + assertNotNull(dimensions); + assertEquals(List.of(orgUnitA.getUid()), dimensions.get("A03MvHHogjR.ou")); + assertFalse(dimensions.get("A03MvHHogjR.ou").contains(boundary.getUid())); + } + + @Test + @DisplayName( + "should include group-resolved org units and group name but not the boundary for stage ou aggregate dimension") + void shouldIncludeGroupResolvedOrgUnitsWithoutBoundaryForStageOuAggregateDimension() { + // Given + Grid grid = new ListGrid(); + + ProgramStage programStage = createProgramStage('S', programA); + programStage.setUid("A03MvHHogjR"); + programStage.setName("Birth"); + + OrganisationUnit boundary = createOrganisationUnit('C'); + String groupUid = "tDZVQ1WtwpA"; + + BaseDimensionalItemObject groupItem = new BaseDimensionalItemObject(); + groupItem.setUid(groupUid); + groupItem.setName("Facility group"); + + QueryItem stageOuItem = + new QueryItem( + new BaseDimensionalItemObject("ou"), + programA, + null, + ValueType.ORGANISATION_UNIT, + AggregationType.NONE, + null) + .withCustomHeader(AnalyticsCustomHeader.forOrgUnit(programStage)); + stageOuItem.setProgramStage(programStage); + stageOuItem.addFilter( + new QueryFilter(QueryOperator.IN, "OU_GROUP-" + groupUid + ";" + boundary.getUid())); + + EventQueryParams params = + new EventQueryParams.Builder() + .withProgram(programA) + .withSkipMeta(false) + .withEndpointAction(AGGREGATE) + .withOrganisationUnits(List.of(orgUnitA)) + .withUserOrgUnits(List.of(orgUnitA)) + .withPeriods(createPeriodDimensions("2023Q1"), "quarterly") + .addItem(stageOuItem) + .build(); + + when(userService.getUserByUsername(anyString())).thenReturn(null); + when(organisationUnitResolver.resolveOrgUnits( + any(EventQueryParams.class), any(QueryItem.class))) + .thenReturn(List.of(orgUnitA.getUid(), orgUnitB.getUid())); + when(organisationUnitResolver.loadOrgUnitDimensionalItem(orgUnitA.getUid(), IdScheme.UID)) + .thenReturn(orgUnitA); + when(organisationUnitResolver.loadOrgUnitDimensionalItem(orgUnitB.getUid(), IdScheme.UID)) + .thenReturn(orgUnitB); + when(organisationUnitResolver.loadOrgUnitDimensionalItem( + "OU_GROUP-" + groupUid, IdScheme.UID)) + .thenReturn(groupItem); + + // When + metadataItemsHandler.addMetadata(grid, params, List.of()); + + // Then + @SuppressWarnings("unchecked") + Map items = (Map) grid.getMetaData().get(ITEMS.getKey()); + assertNotNull(items); + assertTrue(items.containsKey(orgUnitA.getUid())); + assertTrue(items.containsKey(orgUnitB.getUid())); + assertFalse(items.containsKey(boundary.getUid())); + + MetadataItem groupMetadataItem = (MetadataItem) items.get(groupUid); + assertNotNull(groupMetadataItem); + assertEquals("Facility group", groupMetadataItem.getName()); + + @SuppressWarnings("unchecked") + Map> dimensions = + (Map>) grid.getMetaData().get(DIMENSIONS.getKey()); + assertNotNull(dimensions); + assertEquals(List.of(orgUnitA.getUid(), orgUnitB.getUid()), dimensions.get("A03MvHHogjR.ou")); + assertFalse(dimensions.get("A03MvHHogjR.ou").contains(boundary.getUid())); + } } @Nested diff --git a/dhis-2/dhis-test-e2e/config/scripts/process-test-reports.sh b/dhis-2/dhis-test-e2e/config/scripts/process-test-reports.sh index d68e53df6121..73105999a526 100755 --- a/dhis-2/dhis-test-e2e/config/scripts/process-test-reports.sh +++ b/dhis-2/dhis-test-e2e/config/scripts/process-test-reports.sh @@ -6,14 +6,27 @@ set -e echo "Starting test reports processing..." -apt-get update && apt-get install -y git gnupg2 +apt-get update && apt-get install -y git openssh-client -if [ -n "$GPG_PRIVATE_KEY" ]; then - echo "$GPG_PRIVATE_KEY" | base64 -d | gpg --batch --import - echo "$GPG_PASSPHRASE" | gpg --batch --yes --pinentry-mode loopback --passphrase-fd 0 --sign-key "$GPG_KEY_ID" - git config --global user.signingkey "$GPG_KEY_ID" +if [ -n "$SSH_SIGNING_KEY" ]; then + mkdir -p ~/.ssh + chmod 700 ~/.ssh + printf '%s\n' "$SSH_SIGNING_KEY" > ~/.ssh/signing_key + chmod 600 ~/.ssh/signing_key + + # strip the passphrase from this throwaway copy so the container can sign + # non-interactively; the decrypted key only exists in the ephemeral CI container. + ssh-keygen -p -P "$SSH_SIGNING_PASSPHRASE" -N "" -f ~/.ssh/signing_key + + # build an allowed-signers entry so the post-commit verification below can + # confirm the signature locally against the committer email. + ssh-keygen -y -f ~/.ssh/signing_key > ~/.ssh/signing_key.pub + echo "$GIT_COMMITTER_EMAIL $(cat ~/.ssh/signing_key.pub)" > ~/.ssh/allowed_signers + + git config --global gpg.format ssh + git config --global user.signingkey ~/.ssh/signing_key git config --global commit.gpgsign true - git config --global gpg.program gpg + git config --global gpg.ssh.allowedSignersFile ~/.ssh/allowed_signers fi git config --global user.name "$GIT_AUTHOR_NAME" @@ -42,7 +55,8 @@ if git diff --cached --quiet; then else commit_message="Add test results from $(date -Iseconds)" echo "Committing with message: $commit_message" - git commit -m "$commit_message" + git commit -m "$commit_message" + git log --show-signature -1 || true git push origin main fi diff --git a/dhis-2/dhis-test-e2e/docker-compose.e2e.yml b/dhis-2/dhis-test-e2e/docker-compose.e2e.yml index 7e20fa5b8e9e..e3712688108d 100644 --- a/dhis-2/dhis-test-e2e/docker-compose.e2e.yml +++ b/dhis-2/dhis-test-e2e/docker-compose.e2e.yml @@ -20,13 +20,12 @@ services: - ./config/scripts/process-test-reports.sh:/process-test-reports.sh:ro environment: GIT_AUTHOR_NAME: ${GIT_AUTHOR_NAME:-DHIS2 Test Bot} - GIT_AUTHOR_EMAIL: ${GIT_AUTHOR_EMAIL:-test-bot@dhis2.org} + GIT_AUTHOR_EMAIL: ${GIT_AUTHOR_EMAIL:-apps@dhis2.org} GIT_COMMITTER_NAME: ${GIT_COMMITTER_NAME:-DHIS2 Test Bot} - GIT_COMMITTER_EMAIL: ${GIT_COMMITTER_EMAIL:-test-bot@dhis2.org} + GIT_COMMITTER_EMAIL: ${GIT_COMMITTER_EMAIL:-apps@dhis2.org} GITHUB_TOKEN: ${GITHUB_TOKEN} - GPG_PRIVATE_KEY: ${GPG_PRIVATE_KEY} - GPG_KEY_ID: ${GPG_KEY_ID} - GPG_PASSPHRASE: ${GPG_PASSPHRASE} + SSH_SIGNING_KEY: ${SSH_SIGNING_KEY} + SSH_SIGNING_PASSPHRASE: ${SSH_SIGNING_PASSPHRASE} DB_TYPE: ${DB_TYPE:-postgres} TEST_TYPE: ${TEST_TYPE:-e2e} command: ["/process-test-reports.sh"] diff --git a/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/enrollment/query/EnrollmentsQuery7AutoTest.java b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/enrollment/query/EnrollmentsQuery7AutoTest.java index c7bbd4604250..6ee15aadfbae 100644 --- a/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/enrollment/query/EnrollmentsQuery7AutoTest.java +++ b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/enrollment/query/EnrollmentsQuery7AutoTest.java @@ -539,7 +539,7 @@ public void stageAndLevelAndUserOrg() throws JSONException { // 3. Assert metaData. String expectedMetaData = - "{\"pager\":{\"isLastPage\":false,\"pageSize\":100,\"page\":1},\"items\":{\"yu4N82FFeLm\":{\"uid\":\"yu4N82FFeLm\",\"code\":\"OU_204910\",\"name\":\"Mandu\"},\"KXSqt7jv6DU\":{\"uid\":\"KXSqt7jv6DU\",\"code\":\"OU_222627\",\"name\":\"Gorama Mende\"},\"lY93YpCxJqf\":{\"uid\":\"lY93YpCxJqf\",\"code\":\"OU_193249\",\"name\":\"Makari Gbanti\"},\"eROJsBwxQHt\":{\"uid\":\"eROJsBwxQHt\",\"code\":\"OU_222743\",\"name\":\"Gaura\"},\"DxAPPqXvwLy\":{\"uid\":\"DxAPPqXvwLy\",\"code\":\"OU_204929\",\"name\":\"Peje Bongre\"},\"PaqugoqjRIj\":{\"uid\":\"PaqugoqjRIj\",\"code\":\"OU_226225\",\"name\":\"Sulima (Koinadugu)\"},\"gy8rmvYT4cj\":{\"uid\":\"gy8rmvYT4cj\",\"code\":\"OU_247037\",\"name\":\"Ribbi\"},\"RzKeCma9qb1\":{\"uid\":\"RzKeCma9qb1\",\"code\":\"OU_260428\",\"name\":\"Barri\"},\"vULnao2hV5v\":{\"uid\":\"vULnao2hV5v\",\"code\":\"OU_247086\",\"name\":\"Fakunya\"},\"EfWCa0Cc8WW\":{\"uid\":\"EfWCa0Cc8WW\",\"code\":\"OU_255030\",\"name\":\"Masimera\"},\"AovmOHadayb\":{\"uid\":\"AovmOHadayb\",\"code\":\"OU_247044\",\"name\":\"Timidale\"},\"hjpHnHZIniP\":{\"uid\":\"hjpHnHZIniP\",\"code\":\"OU_204887\",\"name\":\"Kissi Tongi\"},\"U6Kr7Gtpidn\":{\"uid\":\"U6Kr7Gtpidn\",\"code\":\"OU_546\",\"name\":\"Kakua\"},\"EYt6ThQDagn\":{\"uid\":\"EYt6ThQDagn\",\"code\":\"OU_222642\",\"name\":\"Koya (kenema)\"},\"iUauWFeH8Qp\":{\"uid\":\"iUauWFeH8Qp\",\"code\":\"OU_197402\",\"name\":\"Bum\"},\"Jiyc4ekaMMh\":{\"uid\":\"Jiyc4ekaMMh\",\"code\":\"OU_247080\",\"name\":\"Kongbora\"},\"FlBemv1NfEC\":{\"uid\":\"FlBemv1NfEC\",\"code\":\"OU_211256\",\"name\":\"Masungbala\"},\"XrF5AvaGcuw\":{\"uid\":\"XrF5AvaGcuw\",\"code\":\"OU_226240\",\"name\":\"Wara Wara Bafodia\"},\"LhaAPLxdSFH\":{\"uid\":\"LhaAPLxdSFH\",\"code\":\"OU_233348\",\"name\":\"Lei\"},\"BmYyh9bZ0sr\":{\"uid\":\"BmYyh9bZ0sr\",\"code\":\"OU_268197\",\"name\":\"Kafe Simira\"},\"pk7bUK5c1Uf\":{\"uid\":\"pk7bUK5c1Uf\",\"code\":\"OU_260397\",\"name\":\"Ya Kpukumu Krim\"},\"zSNUViKdkk3\":{\"uid\":\"zSNUViKdkk3\",\"code\":\"OU_260440\",\"name\":\"Kpaka\"},\"r06ohri9wA9\":{\"uid\":\"r06ohri9wA9\",\"code\":\"OU_211243\",\"name\":\"Samu\"},\"ERmBhYkhV6Y\":{\"uid\":\"ERmBhYkhV6Y\",\"code\":\"OU_204877\",\"name\":\"Njaluahun\"},\"Z9QaI6sxTwW\":{\"uid\":\"Z9QaI6sxTwW\",\"code\":\"OU_247068\",\"name\":\"Kargboro\"},\"daJPPxtIrQn\":{\"uid\":\"daJPPxtIrQn\",\"code\":\"OU_545\",\"name\":\"Jaiama Bongor\"},\"W5fN3G6y1VI\":{\"uid\":\"W5fN3G6y1VI\",\"code\":\"OU_247012\",\"name\":\"Lower Banta\"},\"r1RUyfVBkLp\":{\"uid\":\"r1RUyfVBkLp\",\"code\":\"OU_268169\",\"name\":\"Sambaia Bendugu\"},\"cgqkFdShPzg\":{\"uid\":\"cgqkFdShPzg\",\"code\":\"OU_193213\",\"name\":\"Loreto Clinic\"},\"NNE0YMCDZkO\":{\"uid\":\"NNE0YMCDZkO\",\"code\":\"OU_268225\",\"name\":\"Yoni\"},\"ENHOJz3UH5L\":{\"uid\":\"ENHOJz3UH5L\",\"code\":\"OU_197440\",\"name\":\"BMC\"},\"QywkxFudXrC\":{\"uid\":\"QywkxFudXrC\",\"code\":\"OU_211227\",\"name\":\"Magbema\"},\"jWSIbtKfURj\":{\"uid\":\"jWSIbtKfURj\",\"code\":\"OU_222751\",\"name\":\"Langrama\"},\"J4GiUImJZoE\":{\"uid\":\"J4GiUImJZoE\",\"code\":\"OU_226269\",\"name\":\"Nieni\"},\"CF243RPvNY7\":{\"uid\":\"CF243RPvNY7\",\"code\":\"OU_233359\",\"name\":\"Fiama\"},\"I4jWcnFmgEC\":{\"uid\":\"I4jWcnFmgEC\",\"code\":\"OU_549\",\"name\":\"Niawa Lenga\"},\"cM2BKSrj9F9\":{\"uid\":\"cM2BKSrj9F9\",\"code\":\"OU_204894\",\"name\":\"Luawa\"},\"kvkDWg42lHR\":{\"uid\":\"kvkDWg42lHR\",\"code\":\"OU_233339\",\"name\":\"Kamara\"},\"jPidqyo7cpF\":{\"uid\":\"jPidqyo7cpF\",\"code\":\"OU_247049\",\"name\":\"Bagruwa\"},\"BGGmAwx33dj\":{\"uid\":\"BGGmAwx33dj\",\"code\":\"OU_543\",\"name\":\"Bumpe Ngao\"},\"iGHlidSFdpu\":{\"uid\":\"iGHlidSFdpu\",\"code\":\"OU_233317\",\"name\":\"Soa\"},\"g8DdBm7EmUt\":{\"uid\":\"g8DdBm7EmUt\",\"code\":\"OU_197397\",\"name\":\"Sittia\"},\"ZiOVcrSjSYe\":{\"uid\":\"ZiOVcrSjSYe\",\"code\":\"OU_254976\",\"name\":\"Dibia\"},\"vn9KJsLyP5f\":{\"uid\":\"vn9KJsLyP5f\",\"code\":\"OU_255005\",\"name\":\"Kaffu Bullom\"},\"QlCIp2S9NHs\":{\"uid\":\"QlCIp2S9NHs\",\"code\":\"OU_222682\",\"name\":\"Dodo\"},\"j43EZb15rjI\":{\"uid\":\"j43EZb15rjI\",\"code\":\"OU_193285\",\"name\":\"Sella Limba\"},\"202301\":{\"name\":\"January 2023\"},\"bQiBfA2j5cw\":{\"uid\":\"bQiBfA2j5cw\",\"code\":\"OU_204857\",\"name\":\"Penguia\"},\"NqWaKXcg01b\":{\"uid\":\"NqWaKXcg01b\",\"code\":\"OU_260384\",\"name\":\"Sowa\"},\"VP397wRvePm\":{\"uid\":\"VP397wRvePm\",\"code\":\"OU_197445\",\"name\":\"Nongoba Bullum\"},\"fwxkctgmffZ\":{\"uid\":\"fwxkctgmffZ\",\"code\":\"OU_268163\",\"name\":\"Kholifa Mabang\"},\"QwMiPiME3bA\":{\"uid\":\"QwMiPiME3bA\",\"code\":\"OU_260400\",\"name\":\"Kpanga Kabonde\"},\"Qhmi8IZyPyD\":{\"uid\":\"Qhmi8IZyPyD\",\"code\":\"OU_193245\",\"name\":\"Tambaka\"},\"OTFepb1k9Db\":{\"uid\":\"OTFepb1k9Db\",\"code\":\"OU_226244\",\"name\":\"Mongo\"},\"DBs6e2Oxaj1\":{\"uid\":\"DBs6e2Oxaj1\",\"code\":\"OU_247002\",\"name\":\"Upper Banta\"},\"A03MvHHogjR\":{\"uid\":\"A03MvHHogjR\",\"name\":\"Birth\",\"description\":\"Birth of the baby\"},\"eV4cuxniZgP\":{\"uid\":\"eV4cuxniZgP\",\"code\":\"OU_193224\",\"name\":\"Magbaimba Ndowahun\"},\"xhyjU2SVewz\":{\"uid\":\"xhyjU2SVewz\",\"code\":\"OU_268217\",\"name\":\"Tane\"},\"dGheVylzol6\":{\"uid\":\"dGheVylzol6\",\"code\":\"OU_541\",\"name\":\"Bargbe\"},\"vWbkYPRmKyS\":{\"uid\":\"vWbkYPRmKyS\",\"code\":\"OU_540\",\"name\":\"Baoma\"},\"npWGUj37qDe\":{\"uid\":\"npWGUj37qDe\",\"code\":\"OU_552\",\"name\":\"Valunia\"},\"TA7NvKjsn4A\":{\"uid\":\"TA7NvKjsn4A\",\"code\":\"OU_255041\",\"name\":\"Bureh Kasseh Maconteh\"},\"ZzYYXq4fJie.ou\":{\"name\":\"Organisation unit\"},\"tTUf91fCytl\":{\"uid\":\"tTUf91fCytl\",\"valueType\":\"NUMBER\",\"name\":\"Chiefdom\",\"totalAggregationType\":\"SUM\"},\"myQ4q1W6B4y\":{\"uid\":\"myQ4q1W6B4y\",\"code\":\"OU_222731\",\"name\":\"Dama\"},\"IpHINAT79UW\":{\"uid\":\"IpHINAT79UW\",\"name\":\"Child Programme\"},\"nV3OkyzF4US\":{\"uid\":\"nV3OkyzF4US\",\"code\":\"OU_246991\",\"name\":\"Kori\"},\"X7dWcGerQIm\":{\"uid\":\"X7dWcGerQIm\",\"code\":\"OU_222677\",\"name\":\"Wandor\"},\"qIRCo0MfuGb\":{\"uid\":\"qIRCo0MfuGb\",\"code\":\"OU_211213\",\"name\":\"Gbinleh Dixion\"},\"kbPmt60yi0L\":{\"uid\":\"kbPmt60yi0L\",\"code\":\"OU_211220\",\"name\":\"Bramaia\"},\"eNtRuQrrZeo\":{\"uid\":\"eNtRuQrrZeo\",\"code\":\"OU_260420\",\"name\":\"Galliness Perri\"},\"HV8RTzgcFH3\":{\"uid\":\"HV8RTzgcFH3\",\"code\":\"OU_197432\",\"name\":\"Kwamabai Krim\"},\"KKkLOTpMXGV\":{\"uid\":\"KKkLOTpMXGV\",\"code\":\"OU_193198\",\"name\":\"Bombali Sebora\"},\"Pc3JTyqnsmL\":{\"uid\":\"Pc3JTyqnsmL\",\"code\":\"OU_255020\",\"name\":\"Buya Romende\"},\"hdEuw2ugkVF\":{\"uid\":\"hdEuw2ugkVF\",\"code\":\"OU_222652\",\"name\":\"Lower Bambara\"},\"l7pFejMtUoF\":{\"uid\":\"l7pFejMtUoF\",\"code\":\"OU_222634\",\"name\":\"Tunkia\"},\"K1r3uF6eZ8n\":{\"uid\":\"K1r3uF6eZ8n\",\"code\":\"OU_222725\",\"name\":\"Kandu Lepiema\"},\"VGAFxBXz16y\":{\"uid\":\"VGAFxBXz16y\",\"code\":\"OU_226231\",\"name\":\"Sengbeh\"},\"GE25DpSrqpB\":{\"uid\":\"GE25DpSrqpB\",\"code\":\"OU_204869\",\"name\":\"Malema\"},\"ARZ4y5i4reU\":{\"uid\":\"ARZ4y5i4reU\",\"code\":\"OU_553\",\"name\":\"Wonde\"},\"byp7w6Xd9Df\":{\"uid\":\"byp7w6Xd9Df\",\"code\":\"OU_204933\",\"name\":\"Yawei\"},\"BXJdOLvUrZB\":{\"uid\":\"BXJdOLvUrZB\",\"code\":\"OU_193277\",\"name\":\"Gbendembu Ngowahun\"},\"uKC54fzxRzO\":{\"uid\":\"uKC54fzxRzO\",\"code\":\"OU_222648\",\"name\":\"Niawa\"},\"TQkG0sX9nca\":{\"uid\":\"TQkG0sX9nca\",\"code\":\"OU_233375\",\"name\":\"Gbense\"},\"hRZOIgQ0O1m\":{\"uid\":\"hRZOIgQ0O1m\",\"code\":\"OU_193302\",\"name\":\"Libeisaygahun\"},\"PrJQHI6q7w2\":{\"uid\":\"PrJQHI6q7w2\",\"code\":\"OU_255061\",\"name\":\"Tainkatopa Makama Safrokoh\"},\"USQdmvrHh1Q\":{\"uid\":\"USQdmvrHh1Q\",\"code\":\"OU_247055\",\"name\":\"Kaiyamba\"},\"xGMGhjA3y6J\":{\"uid\":\"xGMGhjA3y6J\",\"code\":\"OU_211262\",\"name\":\"Mambolo\"},\"nOYt1LtFSyU\":{\"uid\":\"nOYt1LtFSyU\",\"code\":\"OU_247025\",\"name\":\"Bumpeh\"},\"ZzYYXq4fJie\":{\"uid\":\"ZzYYXq4fJie\",\"name\":\"Baby Postnatal\",\"description\":\"Baby Postnatal\"},\"e1eIKM1GIF3\":{\"uid\":\"e1eIKM1GIF3\",\"code\":\"OU_193215\",\"name\":\"Gbanti Kamaranka\"},\"lYIM1MXbSYS\":{\"uid\":\"lYIM1MXbSYS\",\"code\":\"OU_204920\",\"name\":\"Dea\"},\"sxRd2XOzFbz\":{\"uid\":\"sxRd2XOzFbz\",\"code\":\"OU_551\",\"name\":\"Tikonko\"},\"d9iMR1MpuIO\":{\"uid\":\"d9iMR1MpuIO\",\"code\":\"OU_260410\",\"name\":\"Soro-Gbeima\"},\"qgQ49DH9a0v\":{\"uid\":\"qgQ49DH9a0v\",\"code\":\"OU_233332\",\"name\":\"Nimiyama\"},\"U09TSwIjG0s\":{\"uid\":\"U09TSwIjG0s\",\"code\":\"OU_222617\",\"name\":\"Nomo\"},\"zFDYIgyGmXG\":{\"uid\":\"zFDYIgyGmXG\",\"code\":\"OU_542\",\"name\":\"Bargbo\"},\"M2qEv692lS6\":{\"uid\":\"M2qEv692lS6\",\"code\":\"OU_233324\",\"name\":\"Tankoro\"},\"qtr8GGlm4gg\":{\"uid\":\"qtr8GGlm4gg\",\"code\":\"OU_278366\",\"name\":\"Rural Western Area\"},\"pRHGAROvuyI\":{\"uid\":\"pRHGAROvuyI\",\"code\":\"OU_254960\",\"name\":\"Koya\"},\"xIKjidMrico\":{\"uid\":\"xIKjidMrico\",\"code\":\"OU_247033\",\"name\":\"Kowa\"},\"GWTIxJO9pRo\":{\"uid\":\"GWTIxJO9pRo\",\"code\":\"OU_233355\",\"name\":\"Gorama Kono\"},\"HWjrSuoNPte\":{\"uid\":\"HWjrSuoNPte\",\"code\":\"OU_254999\",\"name\":\"Sanda Magbolonthor\"},\"UhHipWG7J8b\":{\"uid\":\"UhHipWG7J8b\",\"code\":\"OU_193191\",\"name\":\"Sanda Tendaren\"},\"rXLor9Knq6l\":{\"uid\":\"rXLor9Knq6l\",\"code\":\"OU_268212\",\"name\":\"Kunike Barina\"},\"kU8vhUkAGaT\":{\"uid\":\"kU8vhUkAGaT\",\"code\":\"OU_548\",\"name\":\"Lugbu\"},\"C9uduqDZr9d\":{\"uid\":\"C9uduqDZr9d\",\"code\":\"OU_278311\",\"name\":\"Freetown\"},\"YmmeuGbqOwR\":{\"uid\":\"YmmeuGbqOwR\",\"code\":\"OU_544\",\"name\":\"Gbo\"},\"iEkBZnMDarP\":{\"uid\":\"iEkBZnMDarP\",\"code\":\"OU_226253\",\"name\":\"Folosaba Dembelia\"},\"USER_ORGUNIT\":{\"organisationUnits\":[\"ImspTQPwCqd\"]},\"JsxnA2IywRo\":{\"uid\":\"JsxnA2IywRo\",\"code\":\"OU_204875\",\"name\":\"Kissi Kama\"},\"nlt6j60tCHF\":{\"uid\":\"nlt6j60tCHF\",\"code\":\"OU_260437\",\"name\":\"Mano Sakrim\"},\"g5ptsn0SFX8\":{\"uid\":\"g5ptsn0SFX8\",\"code\":\"OU_233365\",\"name\":\"Sandor\"},\"XG8HGAbrbbL\":{\"uid\":\"XG8HGAbrbbL\",\"code\":\"OU_193267\",\"name\":\"Safroko Limba\"},\"pmxZm7klXBy\":{\"uid\":\"pmxZm7klXBy\",\"code\":\"OU_204924\",\"name\":\"Peje West\"},\"l0ccv2yzfF3\":{\"uid\":\"l0ccv2yzfF3\",\"code\":\"OU_268174\",\"name\":\"Kunike\"},\"YuQRtpLP10I\":{\"uid\":\"YuQRtpLP10I\",\"code\":\"OU_539\",\"name\":\"Badjia\"},\"LfTkc0S4b5k\":{\"uid\":\"LfTkc0S4b5k\",\"code\":\"OU_204915\",\"name\":\"Upper Bambara\"},\"KSdZwrU7Hh6\":{\"uid\":\"KSdZwrU7Hh6\",\"code\":\"OU_204861\",\"name\":\"Jawi\"},\"ajILkI0cfxn\":{\"uid\":\"ajILkI0cfxn\",\"code\":\"OU_233390\",\"name\":\"Gbane\"},\"y5X4mP5XylL\":{\"uid\":\"y5X4mP5XylL\",\"code\":\"OU_211270\",\"name\":\"Tonko Limba\"},\"fwH9ipvXde9\":{\"uid\":\"fwH9ipvXde9\",\"code\":\"OU_193228\",\"name\":\"Biriwa\"},\"KIUCimTXf8Q\":{\"uid\":\"KIUCimTXf8Q\",\"code\":\"OU_222690\",\"name\":\"Nongowa\"},\"vEvs2ckGNQj\":{\"uid\":\"vEvs2ckGNQj\",\"code\":\"OU_226219\",\"name\":\"Kasonko\"},\"XEyIRFd9pct\":{\"uid\":\"XEyIRFd9pct\",\"code\":\"OU_197413\",\"name\":\"Imperi\"},\"cgOy0hRMGu9\":{\"uid\":\"cgOy0hRMGu9\",\"code\":\"OU_197408\",\"name\":\"Sogbini\"},\"EjnIQNVAXGp\":{\"uid\":\"EjnIQNVAXGp\",\"code\":\"OU_233344\",\"name\":\"Mafindor\"},\"x4HaBHHwBML\":{\"uid\":\"x4HaBHHwBML\",\"code\":\"OU_222672\",\"name\":\"Malegohun\"},\"EZPwuUTeIIG\":{\"uid\":\"EZPwuUTeIIG\",\"code\":\"OU_226258\",\"name\":\"Wara Wara Yagala\"},\"N233eZJZ1bh\":{\"uid\":\"N233eZJZ1bh\",\"code\":\"OU_260388\",\"name\":\"Pejeh\"},\"smoyi1iYNK6\":{\"uid\":\"smoyi1iYNK6\",\"code\":\"OU_268191\",\"name\":\"Kalansogoia\"},\"DNRAeXT9IwS\":{\"uid\":\"DNRAeXT9IwS\",\"code\":\"OU_197421\",\"name\":\"Dema\"},\"fRLX08WHWpL\":{\"uid\":\"fRLX08WHWpL\",\"code\":\"OU_254982\",\"name\":\"Lokomasama\"},\"Zoy23SSHCPs\":{\"uid\":\"Zoy23SSHCPs\",\"code\":\"OU_233311\",\"name\":\"Gbane Kandor\"},\"LsYpCyYxSLY\":{\"uid\":\"LsYpCyYxSLY\",\"code\":\"OU_247008\",\"name\":\"Kamaje\"},\"JdqfYTIFZXN\":{\"uid\":\"JdqfYTIFZXN\",\"code\":\"OU_254946\",\"name\":\"Maforki\"},\"EB1zRKdYjdY\":{\"uid\":\"EB1zRKdYjdY\",\"code\":\"OU_197429\",\"name\":\"Bendu Cha\"},\"CG4QD1HC3h4\":{\"uid\":\"CG4QD1HC3h4\",\"code\":\"OU_197436\",\"name\":\"Yawbeko\"},\"ImspTQPwCqd\":{\"uid\":\"ImspTQPwCqd\",\"code\":\"OU_525\",\"name\":\"Sierra Leone\"},\"YpVol7asWvd\":{\"uid\":\"YpVol7asWvd\",\"code\":\"OU_260417\",\"name\":\"Kpanga Krim\"},\"RndxKqQGzUl\":{\"uid\":\"RndxKqQGzUl\",\"code\":\"OU_247018\",\"name\":\"Dasse\"},\"P69SId31eDp\":{\"uid\":\"P69SId31eDp\",\"code\":\"OU_268202\",\"name\":\"Gbonkonlenken\"},\"aWQTfvgPA5v\":{\"uid\":\"aWQTfvgPA5v\",\"code\":\"OU_197424\",\"name\":\"Kpanda Kemoh\"},\"KctpIIucige\":{\"uid\":\"KctpIIucige\",\"code\":\"OU_550\",\"name\":\"Selenga\"},\"Mr4au3jR9bt\":{\"uid\":\"Mr4au3jR9bt\",\"code\":\"OU_226214\",\"name\":\"Dembelia Sinkunia\"},\"L8iA6eLwKNb\":{\"uid\":\"L8iA6eLwKNb\",\"code\":\"OU_193295\",\"name\":\"Paki Masabong\"},\"j0Mtr3xTMjM\":{\"uid\":\"j0Mtr3xTMjM\",\"code\":\"OU_204939\",\"name\":\"Kissi Teng\"},\"DmaLM8WYmWv\":{\"uid\":\"DmaLM8WYmWv\",\"code\":\"OU_233394\",\"name\":\"Nimikoro\"},\"DfUfwjM9am5\":{\"uid\":\"DfUfwjM9am5\",\"code\":\"OU_260392\",\"name\":\"Malen\"},\"FRxcUEwktoV\":{\"uid\":\"FRxcUEwktoV\",\"code\":\"OU_233314\",\"name\":\"Toli\"},\"VCtF1DbspR5\":{\"uid\":\"VCtF1DbspR5\",\"code\":\"OU_197386\",\"name\":\"Jong\"},\"BD9gU0GKlr2\":{\"uid\":\"BD9gU0GKlr2\",\"code\":\"OU_260378\",\"name\":\"Makpele\"},\"GFk45MOxzJJ\":{\"uid\":\"GFk45MOxzJJ\",\"code\":\"OU_226275\",\"name\":\"Neya\"},\"A3Fh37HWBWE\":{\"uid\":\"A3Fh37HWBWE\",\"code\":\"OU_222687\",\"name\":\"Simbaru\"},\"vzup1f6ynON\":{\"uid\":\"vzup1f6ynON\",\"code\":\"OU_222619\",\"name\":\"Small Bo\"},\"Lt8U7GVWvSR\":{\"uid\":\"Lt8U7GVWvSR\",\"code\":\"OU_226263\",\"name\":\"Diang\"},\"JdhagCUEMbj\":{\"uid\":\"JdhagCUEMbj\",\"code\":\"OU_547\",\"name\":\"Komboya\"},\"EVkm2xYcf6Z\":{\"uid\":\"EVkm2xYcf6Z\",\"code\":\"OU_268184\",\"name\":\"Malal Mara\"},\"PQZJPIpTepd\":{\"uid\":\"PQZJPIpTepd\",\"code\":\"OU_268150\",\"name\":\"Kholifa Rowalla\"},\"WXnNDWTiE9r\":{\"uid\":\"WXnNDWTiE9r\",\"code\":\"OU_193239\",\"name\":\"Sanda Loko\"},\"RWvG1aFrr0r\":{\"uid\":\"RWvG1aFrr0r\",\"code\":\"OU_255053\",\"name\":\"Marampa\"}},\"dimensions\":{\"ZzYYXq4fJie.ou\":[\"ImspTQPwCqd\",\"cgqkFdShPzg\",\"YuQRtpLP10I\",\"jPidqyo7cpF\",\"vWbkYPRmKyS\",\"dGheVylzol6\",\"zFDYIgyGmXG\",\"RzKeCma9qb1\",\"EB1zRKdYjdY\",\"fwH9ipvXde9\",\"ENHOJz3UH5L\",\"KKkLOTpMXGV\",\"kbPmt60yi0L\",\"iUauWFeH8Qp\",\"BGGmAwx33dj\",\"nOYt1LtFSyU\",\"TA7NvKjsn4A\",\"Pc3JTyqnsmL\",\"myQ4q1W6B4y\",\"RndxKqQGzUl\",\"lYIM1MXbSYS\",\"DNRAeXT9IwS\",\"Mr4au3jR9bt\",\"Lt8U7GVWvSR\",\"ZiOVcrSjSYe\",\"QlCIp2S9NHs\",\"vULnao2hV5v\",\"CF243RPvNY7\",\"iEkBZnMDarP\",\"C9uduqDZr9d\",\"eNtRuQrrZeo\",\"eROJsBwxQHt\",\"ajILkI0cfxn\",\"Zoy23SSHCPs\",\"e1eIKM1GIF3\",\"BXJdOLvUrZB\",\"TQkG0sX9nca\",\"qIRCo0MfuGb\",\"YmmeuGbqOwR\",\"P69SId31eDp\",\"GWTIxJO9pRo\",\"KXSqt7jv6DU\",\"XEyIRFd9pct\",\"daJPPxtIrQn\",\"KSdZwrU7Hh6\",\"VCtF1DbspR5\",\"BmYyh9bZ0sr\",\"vn9KJsLyP5f\",\"USQdmvrHh1Q\",\"U6Kr7Gtpidn\",\"smoyi1iYNK6\",\"LsYpCyYxSLY\",\"kvkDWg42lHR\",\"K1r3uF6eZ8n\",\"Z9QaI6sxTwW\",\"vEvs2ckGNQj\",\"fwxkctgmffZ\",\"PQZJPIpTepd\",\"JsxnA2IywRo\",\"j0Mtr3xTMjM\",\"hjpHnHZIniP\",\"JdhagCUEMbj\",\"Jiyc4ekaMMh\",\"nV3OkyzF4US\",\"xIKjidMrico\",\"pRHGAROvuyI\",\"EYt6ThQDagn\",\"zSNUViKdkk3\",\"aWQTfvgPA5v\",\"QwMiPiME3bA\",\"YpVol7asWvd\",\"l0ccv2yzfF3\",\"rXLor9Knq6l\",\"HV8RTzgcFH3\",\"jWSIbtKfURj\",\"LhaAPLxdSFH\",\"hRZOIgQ0O1m\",\"fRLX08WHWpL\",\"hdEuw2ugkVF\",\"W5fN3G6y1VI\",\"cM2BKSrj9F9\",\"kU8vhUkAGaT\",\"EjnIQNVAXGp\",\"JdqfYTIFZXN\",\"eV4cuxniZgP\",\"QywkxFudXrC\",\"lY93YpCxJqf\",\"BD9gU0GKlr2\",\"EVkm2xYcf6Z\",\"x4HaBHHwBML\",\"GE25DpSrqpB\",\"DfUfwjM9am5\",\"xGMGhjA3y6J\",\"yu4N82FFeLm\",\"nlt6j60tCHF\",\"RWvG1aFrr0r\",\"EfWCa0Cc8WW\",\"FlBemv1NfEC\",\"OTFepb1k9Db\",\"GFk45MOxzJJ\",\"uKC54fzxRzO\",\"I4jWcnFmgEC\",\"J4GiUImJZoE\",\"DmaLM8WYmWv\",\"qgQ49DH9a0v\",\"ERmBhYkhV6Y\",\"U09TSwIjG0s\",\"VP397wRvePm\",\"KIUCimTXf8Q\",\"L8iA6eLwKNb\",\"DxAPPqXvwLy\",\"pmxZm7klXBy\",\"N233eZJZ1bh\",\"bQiBfA2j5cw\",\"gy8rmvYT4cj\",\"qtr8GGlm4gg\",\"XG8HGAbrbbL\",\"r1RUyfVBkLp\",\"r06ohri9wA9\",\"WXnNDWTiE9r\",\"HWjrSuoNPte\",\"UhHipWG7J8b\",\"g5ptsn0SFX8\",\"KctpIIucige\",\"j43EZb15rjI\",\"VGAFxBXz16y\",\"A3Fh37HWBWE\",\"g8DdBm7EmUt\",\"vzup1f6ynON\",\"iGHlidSFdpu\",\"cgOy0hRMGu9\",\"d9iMR1MpuIO\",\"NqWaKXcg01b\",\"PaqugoqjRIj\",\"PrJQHI6q7w2\",\"Qhmi8IZyPyD\",\"xhyjU2SVewz\",\"M2qEv692lS6\",\"sxRd2XOzFbz\",\"AovmOHadayb\",\"FRxcUEwktoV\",\"y5X4mP5XylL\",\"l7pFejMtUoF\",\"LfTkc0S4b5k\",\"DBs6e2Oxaj1\",\"npWGUj37qDe\",\"X7dWcGerQIm\",\"XrF5AvaGcuw\",\"EZPwuUTeIIG\",\"ARZ4y5i4reU\",\"pk7bUK5c1Uf\",\"CG4QD1HC3h4\",\"byp7w6Xd9Df\",\"NNE0YMCDZkO\"],\"pe\":[]}}"; + "{\"pager\":{\"isLastPage\":false,\"pageSize\":100,\"page\":1},\"items\":{\"yu4N82FFeLm\":{\"uid\":\"yu4N82FFeLm\",\"code\":\"OU_204910\",\"name\":\"Mandu\"},\"KXSqt7jv6DU\":{\"uid\":\"KXSqt7jv6DU\",\"code\":\"OU_222627\",\"name\":\"Gorama Mende\"},\"lY93YpCxJqf\":{\"uid\":\"lY93YpCxJqf\",\"code\":\"OU_193249\",\"name\":\"Makari Gbanti\"},\"eROJsBwxQHt\":{\"uid\":\"eROJsBwxQHt\",\"code\":\"OU_222743\",\"name\":\"Gaura\"},\"DxAPPqXvwLy\":{\"uid\":\"DxAPPqXvwLy\",\"code\":\"OU_204929\",\"name\":\"Peje Bongre\"},\"PaqugoqjRIj\":{\"uid\":\"PaqugoqjRIj\",\"code\":\"OU_226225\",\"name\":\"Sulima (Koinadugu)\"},\"gy8rmvYT4cj\":{\"uid\":\"gy8rmvYT4cj\",\"code\":\"OU_247037\",\"name\":\"Ribbi\"},\"RzKeCma9qb1\":{\"uid\":\"RzKeCma9qb1\",\"code\":\"OU_260428\",\"name\":\"Barri\"},\"vULnao2hV5v\":{\"uid\":\"vULnao2hV5v\",\"code\":\"OU_247086\",\"name\":\"Fakunya\"},\"EfWCa0Cc8WW\":{\"uid\":\"EfWCa0Cc8WW\",\"code\":\"OU_255030\",\"name\":\"Masimera\"},\"AovmOHadayb\":{\"uid\":\"AovmOHadayb\",\"code\":\"OU_247044\",\"name\":\"Timidale\"},\"hjpHnHZIniP\":{\"uid\":\"hjpHnHZIniP\",\"code\":\"OU_204887\",\"name\":\"Kissi Tongi\"},\"U6Kr7Gtpidn\":{\"uid\":\"U6Kr7Gtpidn\",\"code\":\"OU_546\",\"name\":\"Kakua\"},\"EYt6ThQDagn\":{\"uid\":\"EYt6ThQDagn\",\"code\":\"OU_222642\",\"name\":\"Koya (kenema)\"},\"iUauWFeH8Qp\":{\"uid\":\"iUauWFeH8Qp\",\"code\":\"OU_197402\",\"name\":\"Bum\"},\"Jiyc4ekaMMh\":{\"uid\":\"Jiyc4ekaMMh\",\"code\":\"OU_247080\",\"name\":\"Kongbora\"},\"FlBemv1NfEC\":{\"uid\":\"FlBemv1NfEC\",\"code\":\"OU_211256\",\"name\":\"Masungbala\"},\"XrF5AvaGcuw\":{\"uid\":\"XrF5AvaGcuw\",\"code\":\"OU_226240\",\"name\":\"Wara Wara Bafodia\"},\"LhaAPLxdSFH\":{\"uid\":\"LhaAPLxdSFH\",\"code\":\"OU_233348\",\"name\":\"Lei\"},\"BmYyh9bZ0sr\":{\"uid\":\"BmYyh9bZ0sr\",\"code\":\"OU_268197\",\"name\":\"Kafe Simira\"},\"pk7bUK5c1Uf\":{\"uid\":\"pk7bUK5c1Uf\",\"code\":\"OU_260397\",\"name\":\"Ya Kpukumu Krim\"},\"zSNUViKdkk3\":{\"uid\":\"zSNUViKdkk3\",\"code\":\"OU_260440\",\"name\":\"Kpaka\"},\"r06ohri9wA9\":{\"uid\":\"r06ohri9wA9\",\"code\":\"OU_211243\",\"name\":\"Samu\"},\"ERmBhYkhV6Y\":{\"uid\":\"ERmBhYkhV6Y\",\"code\":\"OU_204877\",\"name\":\"Njaluahun\"},\"Z9QaI6sxTwW\":{\"uid\":\"Z9QaI6sxTwW\",\"code\":\"OU_247068\",\"name\":\"Kargboro\"},\"daJPPxtIrQn\":{\"uid\":\"daJPPxtIrQn\",\"code\":\"OU_545\",\"name\":\"Jaiama Bongor\"},\"W5fN3G6y1VI\":{\"uid\":\"W5fN3G6y1VI\",\"code\":\"OU_247012\",\"name\":\"Lower Banta\"},\"r1RUyfVBkLp\":{\"uid\":\"r1RUyfVBkLp\",\"code\":\"OU_268169\",\"name\":\"Sambaia Bendugu\"},\"NNE0YMCDZkO\":{\"uid\":\"NNE0YMCDZkO\",\"code\":\"OU_268225\",\"name\":\"Yoni\"},\"ENHOJz3UH5L\":{\"uid\":\"ENHOJz3UH5L\",\"code\":\"OU_197440\",\"name\":\"BMC\"},\"QywkxFudXrC\":{\"uid\":\"QywkxFudXrC\",\"code\":\"OU_211227\",\"name\":\"Magbema\"},\"jWSIbtKfURj\":{\"uid\":\"jWSIbtKfURj\",\"code\":\"OU_222751\",\"name\":\"Langrama\"},\"J4GiUImJZoE\":{\"uid\":\"J4GiUImJZoE\",\"code\":\"OU_226269\",\"name\":\"Nieni\"},\"CF243RPvNY7\":{\"uid\":\"CF243RPvNY7\",\"code\":\"OU_233359\",\"name\":\"Fiama\"},\"I4jWcnFmgEC\":{\"uid\":\"I4jWcnFmgEC\",\"code\":\"OU_549\",\"name\":\"Niawa Lenga\"},\"cM2BKSrj9F9\":{\"uid\":\"cM2BKSrj9F9\",\"code\":\"OU_204894\",\"name\":\"Luawa\"},\"kvkDWg42lHR\":{\"uid\":\"kvkDWg42lHR\",\"code\":\"OU_233339\",\"name\":\"Kamara\"},\"jPidqyo7cpF\":{\"uid\":\"jPidqyo7cpF\",\"code\":\"OU_247049\",\"name\":\"Bagruwa\"},\"BGGmAwx33dj\":{\"uid\":\"BGGmAwx33dj\",\"code\":\"OU_543\",\"name\":\"Bumpe Ngao\"},\"iGHlidSFdpu\":{\"uid\":\"iGHlidSFdpu\",\"code\":\"OU_233317\",\"name\":\"Soa\"},\"g8DdBm7EmUt\":{\"uid\":\"g8DdBm7EmUt\",\"code\":\"OU_197397\",\"name\":\"Sittia\"},\"ZiOVcrSjSYe\":{\"uid\":\"ZiOVcrSjSYe\",\"code\":\"OU_254976\",\"name\":\"Dibia\"},\"vn9KJsLyP5f\":{\"uid\":\"vn9KJsLyP5f\",\"code\":\"OU_255005\",\"name\":\"Kaffu Bullom\"},\"QlCIp2S9NHs\":{\"uid\":\"QlCIp2S9NHs\",\"code\":\"OU_222682\",\"name\":\"Dodo\"},\"j43EZb15rjI\":{\"uid\":\"j43EZb15rjI\",\"code\":\"OU_193285\",\"name\":\"Sella Limba\"},\"202301\":{\"name\":\"January 2023\"},\"bQiBfA2j5cw\":{\"uid\":\"bQiBfA2j5cw\",\"code\":\"OU_204857\",\"name\":\"Penguia\"},\"NqWaKXcg01b\":{\"uid\":\"NqWaKXcg01b\",\"code\":\"OU_260384\",\"name\":\"Sowa\"},\"VP397wRvePm\":{\"uid\":\"VP397wRvePm\",\"code\":\"OU_197445\",\"name\":\"Nongoba Bullum\"},\"fwxkctgmffZ\":{\"uid\":\"fwxkctgmffZ\",\"code\":\"OU_268163\",\"name\":\"Kholifa Mabang\"},\"QwMiPiME3bA\":{\"uid\":\"QwMiPiME3bA\",\"code\":\"OU_260400\",\"name\":\"Kpanga Kabonde\"},\"Qhmi8IZyPyD\":{\"uid\":\"Qhmi8IZyPyD\",\"code\":\"OU_193245\",\"name\":\"Tambaka\"},\"OTFepb1k9Db\":{\"uid\":\"OTFepb1k9Db\",\"code\":\"OU_226244\",\"name\":\"Mongo\"},\"DBs6e2Oxaj1\":{\"uid\":\"DBs6e2Oxaj1\",\"code\":\"OU_247002\",\"name\":\"Upper Banta\"},\"A03MvHHogjR\":{\"uid\":\"A03MvHHogjR\",\"name\":\"Birth\",\"description\":\"Birth of the baby\"},\"eV4cuxniZgP\":{\"uid\":\"eV4cuxniZgP\",\"code\":\"OU_193224\",\"name\":\"Magbaimba Ndowahun\"},\"xhyjU2SVewz\":{\"uid\":\"xhyjU2SVewz\",\"code\":\"OU_268217\",\"name\":\"Tane\"},\"dGheVylzol6\":{\"uid\":\"dGheVylzol6\",\"code\":\"OU_541\",\"name\":\"Bargbe\"},\"vWbkYPRmKyS\":{\"uid\":\"vWbkYPRmKyS\",\"code\":\"OU_540\",\"name\":\"Baoma\"},\"npWGUj37qDe\":{\"uid\":\"npWGUj37qDe\",\"code\":\"OU_552\",\"name\":\"Valunia\"},\"TA7NvKjsn4A\":{\"uid\":\"TA7NvKjsn4A\",\"code\":\"OU_255041\",\"name\":\"Bureh Kasseh Maconteh\"},\"ZzYYXq4fJie.ou\":{\"name\":\"Organisation unit\"},\"tTUf91fCytl\":{\"uid\":\"tTUf91fCytl\",\"valueType\":\"NUMBER\",\"name\":\"Chiefdom\",\"totalAggregationType\":\"SUM\"},\"myQ4q1W6B4y\":{\"uid\":\"myQ4q1W6B4y\",\"code\":\"OU_222731\",\"name\":\"Dama\"},\"IpHINAT79UW\":{\"uid\":\"IpHINAT79UW\",\"name\":\"Child Programme\"},\"nV3OkyzF4US\":{\"uid\":\"nV3OkyzF4US\",\"code\":\"OU_246991\",\"name\":\"Kori\"},\"X7dWcGerQIm\":{\"uid\":\"X7dWcGerQIm\",\"code\":\"OU_222677\",\"name\":\"Wandor\"},\"qIRCo0MfuGb\":{\"uid\":\"qIRCo0MfuGb\",\"code\":\"OU_211213\",\"name\":\"Gbinleh Dixion\"},\"kbPmt60yi0L\":{\"uid\":\"kbPmt60yi0L\",\"code\":\"OU_211220\",\"name\":\"Bramaia\"},\"eNtRuQrrZeo\":{\"uid\":\"eNtRuQrrZeo\",\"code\":\"OU_260420\",\"name\":\"Galliness Perri\"},\"HV8RTzgcFH3\":{\"uid\":\"HV8RTzgcFH3\",\"code\":\"OU_197432\",\"name\":\"Kwamabai Krim\"},\"KKkLOTpMXGV\":{\"uid\":\"KKkLOTpMXGV\",\"code\":\"OU_193198\",\"name\":\"Bombali Sebora\"},\"Pc3JTyqnsmL\":{\"uid\":\"Pc3JTyqnsmL\",\"code\":\"OU_255020\",\"name\":\"Buya Romende\"},\"hdEuw2ugkVF\":{\"uid\":\"hdEuw2ugkVF\",\"code\":\"OU_222652\",\"name\":\"Lower Bambara\"},\"l7pFejMtUoF\":{\"uid\":\"l7pFejMtUoF\",\"code\":\"OU_222634\",\"name\":\"Tunkia\"},\"K1r3uF6eZ8n\":{\"uid\":\"K1r3uF6eZ8n\",\"code\":\"OU_222725\",\"name\":\"Kandu Lepiema\"},\"VGAFxBXz16y\":{\"uid\":\"VGAFxBXz16y\",\"code\":\"OU_226231\",\"name\":\"Sengbeh\"},\"GE25DpSrqpB\":{\"uid\":\"GE25DpSrqpB\",\"code\":\"OU_204869\",\"name\":\"Malema\"},\"ARZ4y5i4reU\":{\"uid\":\"ARZ4y5i4reU\",\"code\":\"OU_553\",\"name\":\"Wonde\"},\"byp7w6Xd9Df\":{\"uid\":\"byp7w6Xd9Df\",\"code\":\"OU_204933\",\"name\":\"Yawei\"},\"BXJdOLvUrZB\":{\"uid\":\"BXJdOLvUrZB\",\"code\":\"OU_193277\",\"name\":\"Gbendembu Ngowahun\"},\"uKC54fzxRzO\":{\"uid\":\"uKC54fzxRzO\",\"code\":\"OU_222648\",\"name\":\"Niawa\"},\"TQkG0sX9nca\":{\"uid\":\"TQkG0sX9nca\",\"code\":\"OU_233375\",\"name\":\"Gbense\"},\"hRZOIgQ0O1m\":{\"uid\":\"hRZOIgQ0O1m\",\"code\":\"OU_193302\",\"name\":\"Libeisaygahun\"},\"PrJQHI6q7w2\":{\"uid\":\"PrJQHI6q7w2\",\"code\":\"OU_255061\",\"name\":\"Tainkatopa Makama Safrokoh\"},\"USQdmvrHh1Q\":{\"uid\":\"USQdmvrHh1Q\",\"code\":\"OU_247055\",\"name\":\"Kaiyamba\"},\"xGMGhjA3y6J\":{\"uid\":\"xGMGhjA3y6J\",\"code\":\"OU_211262\",\"name\":\"Mambolo\"},\"nOYt1LtFSyU\":{\"uid\":\"nOYt1LtFSyU\",\"code\":\"OU_247025\",\"name\":\"Bumpeh\"},\"ZzYYXq4fJie\":{\"uid\":\"ZzYYXq4fJie\",\"name\":\"Baby Postnatal\",\"description\":\"Baby Postnatal\"},\"e1eIKM1GIF3\":{\"uid\":\"e1eIKM1GIF3\",\"code\":\"OU_193215\",\"name\":\"Gbanti Kamaranka\"},\"lYIM1MXbSYS\":{\"uid\":\"lYIM1MXbSYS\",\"code\":\"OU_204920\",\"name\":\"Dea\"},\"sxRd2XOzFbz\":{\"uid\":\"sxRd2XOzFbz\",\"code\":\"OU_551\",\"name\":\"Tikonko\"},\"d9iMR1MpuIO\":{\"uid\":\"d9iMR1MpuIO\",\"code\":\"OU_260410\",\"name\":\"Soro-Gbeima\"},\"qgQ49DH9a0v\":{\"uid\":\"qgQ49DH9a0v\",\"code\":\"OU_233332\",\"name\":\"Nimiyama\"},\"U09TSwIjG0s\":{\"uid\":\"U09TSwIjG0s\",\"code\":\"OU_222617\",\"name\":\"Nomo\"},\"zFDYIgyGmXG\":{\"uid\":\"zFDYIgyGmXG\",\"code\":\"OU_542\",\"name\":\"Bargbo\"},\"M2qEv692lS6\":{\"uid\":\"M2qEv692lS6\",\"code\":\"OU_233324\",\"name\":\"Tankoro\"},\"qtr8GGlm4gg\":{\"uid\":\"qtr8GGlm4gg\",\"code\":\"OU_278366\",\"name\":\"Rural Western Area\"},\"pRHGAROvuyI\":{\"uid\":\"pRHGAROvuyI\",\"code\":\"OU_254960\",\"name\":\"Koya\"},\"xIKjidMrico\":{\"uid\":\"xIKjidMrico\",\"code\":\"OU_247033\",\"name\":\"Kowa\"},\"GWTIxJO9pRo\":{\"uid\":\"GWTIxJO9pRo\",\"code\":\"OU_233355\",\"name\":\"Gorama Kono\"},\"HWjrSuoNPte\":{\"uid\":\"HWjrSuoNPte\",\"code\":\"OU_254999\",\"name\":\"Sanda Magbolonthor\"},\"UhHipWG7J8b\":{\"uid\":\"UhHipWG7J8b\",\"code\":\"OU_193191\",\"name\":\"Sanda Tendaren\"},\"rXLor9Knq6l\":{\"uid\":\"rXLor9Knq6l\",\"code\":\"OU_268212\",\"name\":\"Kunike Barina\"},\"kU8vhUkAGaT\":{\"uid\":\"kU8vhUkAGaT\",\"code\":\"OU_548\",\"name\":\"Lugbu\"},\"C9uduqDZr9d\":{\"uid\":\"C9uduqDZr9d\",\"code\":\"OU_278311\",\"name\":\"Freetown\"},\"YmmeuGbqOwR\":{\"uid\":\"YmmeuGbqOwR\",\"code\":\"OU_544\",\"name\":\"Gbo\"},\"iEkBZnMDarP\":{\"uid\":\"iEkBZnMDarP\",\"code\":\"OU_226253\",\"name\":\"Folosaba Dembelia\"},\"USER_ORGUNIT\":{\"organisationUnits\":[\"ImspTQPwCqd\"]},\"JsxnA2IywRo\":{\"uid\":\"JsxnA2IywRo\",\"code\":\"OU_204875\",\"name\":\"Kissi Kama\"},\"nlt6j60tCHF\":{\"uid\":\"nlt6j60tCHF\",\"code\":\"OU_260437\",\"name\":\"Mano Sakrim\"},\"g5ptsn0SFX8\":{\"uid\":\"g5ptsn0SFX8\",\"code\":\"OU_233365\",\"name\":\"Sandor\"},\"XG8HGAbrbbL\":{\"uid\":\"XG8HGAbrbbL\",\"code\":\"OU_193267\",\"name\":\"Safroko Limba\"},\"pmxZm7klXBy\":{\"uid\":\"pmxZm7klXBy\",\"code\":\"OU_204924\",\"name\":\"Peje West\"},\"l0ccv2yzfF3\":{\"uid\":\"l0ccv2yzfF3\",\"code\":\"OU_268174\",\"name\":\"Kunike\"},\"YuQRtpLP10I\":{\"uid\":\"YuQRtpLP10I\",\"code\":\"OU_539\",\"name\":\"Badjia\"},\"LfTkc0S4b5k\":{\"uid\":\"LfTkc0S4b5k\",\"code\":\"OU_204915\",\"name\":\"Upper Bambara\"},\"KSdZwrU7Hh6\":{\"uid\":\"KSdZwrU7Hh6\",\"code\":\"OU_204861\",\"name\":\"Jawi\"},\"ajILkI0cfxn\":{\"uid\":\"ajILkI0cfxn\",\"code\":\"OU_233390\",\"name\":\"Gbane\"},\"y5X4mP5XylL\":{\"uid\":\"y5X4mP5XylL\",\"code\":\"OU_211270\",\"name\":\"Tonko Limba\"},\"fwH9ipvXde9\":{\"uid\":\"fwH9ipvXde9\",\"code\":\"OU_193228\",\"name\":\"Biriwa\"},\"KIUCimTXf8Q\":{\"uid\":\"KIUCimTXf8Q\",\"code\":\"OU_222690\",\"name\":\"Nongowa\"},\"vEvs2ckGNQj\":{\"uid\":\"vEvs2ckGNQj\",\"code\":\"OU_226219\",\"name\":\"Kasonko\"},\"XEyIRFd9pct\":{\"uid\":\"XEyIRFd9pct\",\"code\":\"OU_197413\",\"name\":\"Imperi\"},\"cgOy0hRMGu9\":{\"uid\":\"cgOy0hRMGu9\",\"code\":\"OU_197408\",\"name\":\"Sogbini\"},\"EjnIQNVAXGp\":{\"uid\":\"EjnIQNVAXGp\",\"code\":\"OU_233344\",\"name\":\"Mafindor\"},\"x4HaBHHwBML\":{\"uid\":\"x4HaBHHwBML\",\"code\":\"OU_222672\",\"name\":\"Malegohun\"},\"EZPwuUTeIIG\":{\"uid\":\"EZPwuUTeIIG\",\"code\":\"OU_226258\",\"name\":\"Wara Wara Yagala\"},\"N233eZJZ1bh\":{\"uid\":\"N233eZJZ1bh\",\"code\":\"OU_260388\",\"name\":\"Pejeh\"},\"smoyi1iYNK6\":{\"uid\":\"smoyi1iYNK6\",\"code\":\"OU_268191\",\"name\":\"Kalansogoia\"},\"DNRAeXT9IwS\":{\"uid\":\"DNRAeXT9IwS\",\"code\":\"OU_197421\",\"name\":\"Dema\"},\"fRLX08WHWpL\":{\"uid\":\"fRLX08WHWpL\",\"code\":\"OU_254982\",\"name\":\"Lokomasama\"},\"Zoy23SSHCPs\":{\"uid\":\"Zoy23SSHCPs\",\"code\":\"OU_233311\",\"name\":\"Gbane Kandor\"},\"LsYpCyYxSLY\":{\"uid\":\"LsYpCyYxSLY\",\"code\":\"OU_247008\",\"name\":\"Kamaje\"},\"JdqfYTIFZXN\":{\"uid\":\"JdqfYTIFZXN\",\"code\":\"OU_254946\",\"name\":\"Maforki\"},\"EB1zRKdYjdY\":{\"uid\":\"EB1zRKdYjdY\",\"code\":\"OU_197429\",\"name\":\"Bendu Cha\"},\"CG4QD1HC3h4\":{\"uid\":\"CG4QD1HC3h4\",\"code\":\"OU_197436\",\"name\":\"Yawbeko\"},\"YpVol7asWvd\":{\"uid\":\"YpVol7asWvd\",\"code\":\"OU_260417\",\"name\":\"Kpanga Krim\"},\"RndxKqQGzUl\":{\"uid\":\"RndxKqQGzUl\",\"code\":\"OU_247018\",\"name\":\"Dasse\"},\"P69SId31eDp\":{\"uid\":\"P69SId31eDp\",\"code\":\"OU_268202\",\"name\":\"Gbonkonlenken\"},\"aWQTfvgPA5v\":{\"uid\":\"aWQTfvgPA5v\",\"code\":\"OU_197424\",\"name\":\"Kpanda Kemoh\"},\"KctpIIucige\":{\"uid\":\"KctpIIucige\",\"code\":\"OU_550\",\"name\":\"Selenga\"},\"Mr4au3jR9bt\":{\"uid\":\"Mr4au3jR9bt\",\"code\":\"OU_226214\",\"name\":\"Dembelia Sinkunia\"},\"L8iA6eLwKNb\":{\"uid\":\"L8iA6eLwKNb\",\"code\":\"OU_193295\",\"name\":\"Paki Masabong\"},\"j0Mtr3xTMjM\":{\"uid\":\"j0Mtr3xTMjM\",\"code\":\"OU_204939\",\"name\":\"Kissi Teng\"},\"DmaLM8WYmWv\":{\"uid\":\"DmaLM8WYmWv\",\"code\":\"OU_233394\",\"name\":\"Nimikoro\"},\"DfUfwjM9am5\":{\"uid\":\"DfUfwjM9am5\",\"code\":\"OU_260392\",\"name\":\"Malen\"},\"FRxcUEwktoV\":{\"uid\":\"FRxcUEwktoV\",\"code\":\"OU_233314\",\"name\":\"Toli\"},\"VCtF1DbspR5\":{\"uid\":\"VCtF1DbspR5\",\"code\":\"OU_197386\",\"name\":\"Jong\"},\"BD9gU0GKlr2\":{\"uid\":\"BD9gU0GKlr2\",\"code\":\"OU_260378\",\"name\":\"Makpele\"},\"GFk45MOxzJJ\":{\"uid\":\"GFk45MOxzJJ\",\"code\":\"OU_226275\",\"name\":\"Neya\"},\"A3Fh37HWBWE\":{\"uid\":\"A3Fh37HWBWE\",\"code\":\"OU_222687\",\"name\":\"Simbaru\"},\"vzup1f6ynON\":{\"uid\":\"vzup1f6ynON\",\"code\":\"OU_222619\",\"name\":\"Small Bo\"},\"Lt8U7GVWvSR\":{\"uid\":\"Lt8U7GVWvSR\",\"code\":\"OU_226263\",\"name\":\"Diang\"},\"pe\":{\"uid\":\"pe\",\"dimensionType\":\"PERIOD\"},\"JdhagCUEMbj\":{\"uid\":\"JdhagCUEMbj\",\"code\":\"OU_547\",\"name\":\"Komboya\"},\"EVkm2xYcf6Z\":{\"uid\":\"EVkm2xYcf6Z\",\"code\":\"OU_268184\",\"name\":\"Malal Mara\"},\"PQZJPIpTepd\":{\"uid\":\"PQZJPIpTepd\",\"code\":\"OU_268150\",\"name\":\"Kholifa Rowalla\"},\"WXnNDWTiE9r\":{\"uid\":\"WXnNDWTiE9r\",\"code\":\"OU_193239\",\"name\":\"Sanda Loko\"},\"RWvG1aFrr0r\":{\"uid\":\"RWvG1aFrr0r\",\"code\":\"OU_255053\",\"name\":\"Marampa\"}},\"dimensions\":{\"ZzYYXq4fJie.ou\":[\"YuQRtpLP10I\",\"jPidqyo7cpF\",\"vWbkYPRmKyS\",\"dGheVylzol6\",\"zFDYIgyGmXG\",\"RzKeCma9qb1\",\"EB1zRKdYjdY\",\"fwH9ipvXde9\",\"ENHOJz3UH5L\",\"KKkLOTpMXGV\",\"kbPmt60yi0L\",\"iUauWFeH8Qp\",\"BGGmAwx33dj\",\"nOYt1LtFSyU\",\"TA7NvKjsn4A\",\"Pc3JTyqnsmL\",\"myQ4q1W6B4y\",\"RndxKqQGzUl\",\"lYIM1MXbSYS\",\"DNRAeXT9IwS\",\"Mr4au3jR9bt\",\"Lt8U7GVWvSR\",\"ZiOVcrSjSYe\",\"QlCIp2S9NHs\",\"vULnao2hV5v\",\"CF243RPvNY7\",\"iEkBZnMDarP\",\"C9uduqDZr9d\",\"eNtRuQrrZeo\",\"eROJsBwxQHt\",\"ajILkI0cfxn\",\"Zoy23SSHCPs\",\"e1eIKM1GIF3\",\"BXJdOLvUrZB\",\"TQkG0sX9nca\",\"qIRCo0MfuGb\",\"YmmeuGbqOwR\",\"P69SId31eDp\",\"GWTIxJO9pRo\",\"KXSqt7jv6DU\",\"XEyIRFd9pct\",\"daJPPxtIrQn\",\"KSdZwrU7Hh6\",\"VCtF1DbspR5\",\"BmYyh9bZ0sr\",\"vn9KJsLyP5f\",\"USQdmvrHh1Q\",\"U6Kr7Gtpidn\",\"smoyi1iYNK6\",\"LsYpCyYxSLY\",\"kvkDWg42lHR\",\"K1r3uF6eZ8n\",\"Z9QaI6sxTwW\",\"vEvs2ckGNQj\",\"fwxkctgmffZ\",\"PQZJPIpTepd\",\"JsxnA2IywRo\",\"j0Mtr3xTMjM\",\"hjpHnHZIniP\",\"JdhagCUEMbj\",\"Jiyc4ekaMMh\",\"nV3OkyzF4US\",\"xIKjidMrico\",\"pRHGAROvuyI\",\"EYt6ThQDagn\",\"zSNUViKdkk3\",\"aWQTfvgPA5v\",\"QwMiPiME3bA\",\"YpVol7asWvd\",\"l0ccv2yzfF3\",\"rXLor9Knq6l\",\"HV8RTzgcFH3\",\"jWSIbtKfURj\",\"LhaAPLxdSFH\",\"hRZOIgQ0O1m\",\"fRLX08WHWpL\",\"hdEuw2ugkVF\",\"W5fN3G6y1VI\",\"cM2BKSrj9F9\",\"kU8vhUkAGaT\",\"EjnIQNVAXGp\",\"JdqfYTIFZXN\",\"eV4cuxniZgP\",\"QywkxFudXrC\",\"lY93YpCxJqf\",\"BD9gU0GKlr2\",\"EVkm2xYcf6Z\",\"x4HaBHHwBML\",\"GE25DpSrqpB\",\"DfUfwjM9am5\",\"xGMGhjA3y6J\",\"yu4N82FFeLm\",\"nlt6j60tCHF\",\"RWvG1aFrr0r\",\"EfWCa0Cc8WW\",\"FlBemv1NfEC\",\"OTFepb1k9Db\",\"GFk45MOxzJJ\",\"uKC54fzxRzO\",\"I4jWcnFmgEC\",\"J4GiUImJZoE\",\"DmaLM8WYmWv\",\"qgQ49DH9a0v\",\"ERmBhYkhV6Y\",\"U09TSwIjG0s\",\"VP397wRvePm\",\"KIUCimTXf8Q\",\"L8iA6eLwKNb\",\"DxAPPqXvwLy\",\"pmxZm7klXBy\",\"N233eZJZ1bh\",\"bQiBfA2j5cw\",\"gy8rmvYT4cj\",\"qtr8GGlm4gg\",\"XG8HGAbrbbL\",\"r1RUyfVBkLp\",\"r06ohri9wA9\",\"WXnNDWTiE9r\",\"HWjrSuoNPte\",\"UhHipWG7J8b\",\"g5ptsn0SFX8\",\"KctpIIucige\",\"j43EZb15rjI\",\"VGAFxBXz16y\",\"A3Fh37HWBWE\",\"g8DdBm7EmUt\",\"vzup1f6ynON\",\"iGHlidSFdpu\",\"cgOy0hRMGu9\",\"d9iMR1MpuIO\",\"NqWaKXcg01b\",\"PaqugoqjRIj\",\"PrJQHI6q7w2\",\"Qhmi8IZyPyD\",\"xhyjU2SVewz\",\"M2qEv692lS6\",\"sxRd2XOzFbz\",\"AovmOHadayb\",\"FRxcUEwktoV\",\"y5X4mP5XylL\",\"l7pFejMtUoF\",\"LfTkc0S4b5k\",\"DBs6e2Oxaj1\",\"npWGUj37qDe\",\"X7dWcGerQIm\",\"XrF5AvaGcuw\",\"EZPwuUTeIIG\",\"ARZ4y5i4reU\",\"pk7bUK5c1Uf\",\"CG4QD1HC3h4\",\"byp7w6Xd9Df\",\"NNE0YMCDZkO\"],\"pe\":[]}}"; String actualMetaData = new JSONObject((Map) response.extract("metaData")).toString(); assertEquals(expectedMetaData, actualMetaData, false); diff --git a/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/event/aggregate/EventsAggregate11AutoTest.java b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/event/aggregate/EventsAggregate11AutoTest.java index b1a41fb3f6b3..3e2b1a50d414 100644 --- a/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/event/aggregate/EventsAggregate11AutoTest.java +++ b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/event/aggregate/EventsAggregate11AutoTest.java @@ -1332,4 +1332,69 @@ public void stageValueWithBooleanDataElement() throws JSONException { validateRowExists( response, actualHeaders, Map.of("A03MvHHogjR.eventdate", "202106", "value", "474.0")); } + + @Test + public void stageAndOuLevelWithBoundary() throws JSONException { + // Read the 'expect.postgis' system property at runtime to adapt assertions. + boolean expectPostgis = isPostgres(); + + // Given + QueryParamsBuilder params = + new QueryParamsBuilder() + .add("displayProperty=NAME") + .add("sortOrder=desc") + .add("totalPages=false") + .add("outputType=EVENT") + .add("dimension=pe:2022,ZkbAXlQUYJG.ou:LEVEL-wjP19dkFeIk;ImspTQPwCqd"); + + // When + ApiResponse response = actions.aggregate().get("ur1Edk5Oe2n", JSON, JSON, params); + + // Then + // 1. Validate Response Structure (Counts, Headers, Height/Width) + // This helper checks basic counts and dimensions, adapting based on the runtime + // 'expectPostgis' flag. + validateResponseStructure( + response, + expectPostgis, + 1, + 3, + 3); // Pass runtime flag, row count, and expected header counts + + // 2. Extract Headers into a List of Maps for easy access by name + List> actualHeaders = + response.extractList("headers", Map.class).stream() + .map(obj -> (Map) obj) // Ensure correct type + .collect(Collectors.toList()); + + // 3. Assert metaData. + String expectedMetaData = + "{\"items\":{\"jUb8gELQApl\":{\"name\":\"Kailahun\"},\"TEQlaapDQoK\":{\"name\":\"Port Loko\"},\"eIQbndfxQMb\":{\"name\":\"Tonkolili\"},\"Vth0fbpFcsO\":{\"name\":\"Kono\"},\"jdRD35YwbRH\":{\"name\":\"Sputum smear microscopy test\"},\"PMa2VCrupOd\":{\"name\":\"Kambia\"},\"2022\":{\"name\":\"2022\"},\"O6uvpzGd5pu\":{\"name\":\"Bo\"},\"bL4ooGhyHRQ\":{\"name\":\"Pujehun\"},\"kJq2mPyFEHo\":{\"name\":\"Kenema\"},\"ZkbAXlQUYJG\":{\"name\":\"TB visit\"},\"wjP19dkFeIk\":{\"name\":\"District\"},\"fdc6uOvgoji\":{\"name\":\"Bombali\"},\"at6UHUQatSo\":{\"name\":\"Western Area\"},\"EPEcjy3FWmI\":{\"name\":\"Lab monitoring\"},\"pe\":{\"name\":\"Period\"},\"ur1Edk5Oe2n\":{\"name\":\"TB program\"},\"ZkbAXlQUYJG.ou\":{\"name\":\"Organisation unit\"},\"lc3eMKXaEfw\":{\"name\":\"Bonthe\"},\"qhqAxPSTUXp\":{\"name\":\"Koinadugu\"},\"jmIPBj66vD6\":{\"name\":\"Moyamba\"}},\"dimensions\":{\"pe\":[\"2022\"],\"ZkbAXlQUYJG.ou\":[\"O6uvpzGd5pu\",\"fdc6uOvgoji\",\"lc3eMKXaEfw\",\"jUb8gELQApl\",\"PMa2VCrupOd\",\"kJq2mPyFEHo\",\"qhqAxPSTUXp\",\"Vth0fbpFcsO\",\"jmIPBj66vD6\",\"TEQlaapDQoK\",\"bL4ooGhyHRQ\",\"eIQbndfxQMb\",\"at6UHUQatSo\"]}}"; + String actualMetaData = new JSONObject((Map) response.extract("metaData")).toString(); + assertEquals(expectedMetaData, actualMetaData, false); + + // 4. Validate Headers By Name (conditionally checking PostGIS headers). + validateHeaderPropertiesByName( + response, + actualHeaders, + "ZkbAXlQUYJG.ou", + "Organisation unit", + "ORGANISATION_UNIT", + "org.hisp.dhis.organisationunit.OrganisationUnit", + false, + true); + validateHeaderPropertiesByName( + response, actualHeaders, "pe", "Period", "TEXT", "java.lang.String", false, true); + validateHeaderPropertiesByName( + response, actualHeaders, "value", "Value", "NUMBER", "java.lang.Double", false, false); + + // rowContext not found or empty in the response, skipping assertions. + + // 7. Assert row existence by value (unsorted results - validates all columns). + // Validate row exists with values from original row index 0 + validateRowExists( + response, + actualHeaders, + Map.of("ZkbAXlQUYJG.ou", "O6uvpzGd5pu", "pe", "2022", "value", "6")); + } } diff --git a/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/event/query/EventsQuery6AutoTest.java b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/event/query/EventsQuery6AutoTest.java index 1e21b0cf0616..34d6ced4192d 100644 --- a/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/event/query/EventsQuery6AutoTest.java +++ b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/event/query/EventsQuery6AutoTest.java @@ -4219,4 +4219,277 @@ public void verifyDimensionAcceptsTeaAttributeAsHeader() { response, actualHeaders, "cejWyOfXge6", "Gender", "TEXT", "java.lang.String", false, true); response.validate().statusCode(200).body("headers", hasSize(3)); } + + @Test + public void stageAndOuLevelWithBoundary() throws JSONException { + // Read the 'expect.postgis' system property at runtime to adapt assertions. + boolean expectPostgis = isPostgres(); + + // Given + QueryParamsBuilder params = + new QueryParamsBuilder() + .add("displayProperty=NAME") + .add("outputType=EVENT") + .add("pageSize=100") + .add("page=1") + .add("dimension=ZkbAXlQUYJG.ou:LEVEL-wjP19dkFeIk;ImspTQPwCqd,pe:2022") + .add("desc=eventdate,lastupdated"); + + // When + ApiResponse response = actions.query().get("ur1Edk5Oe2n", JSON, JSON, params); + + // Then + // 1. Validate Response Structure (Counts, Headers, Height/Width) + // This helper checks basic counts and dimensions, adapting based on the runtime + // 'expectPostgis' flag. + validateResponseStructure( + response, + expectPostgis, + 4, + 24, + 20); // Pass runtime flag, row count, and expected header counts + + // 2. Extract Headers into a List of Maps for easy access by name + List> actualHeaders = + response.extractList("headers", Map.class).stream() + .map(obj -> (Map) obj) // Ensure correct type + .collect(Collectors.toList()); + + // 3. Assert metaData. + String expectedMetaData = + "{\"pager\":{\"page\":1,\"total\":4,\"pageSize\":100,\"pageCount\":1},\"items\":{\"TEQlaapDQoK\":{\"code\":\"OU_254945\",\"name\":\"Port Loko\"},\"eIQbndfxQMb\":{\"code\":\"OU_268149\",\"name\":\"Tonkolili\"},\"jUb8gELQApl\":{\"code\":\"OU_204856\",\"name\":\"Kailahun\"},\"Vth0fbpFcsO\":{\"code\":\"OU_233310\",\"name\":\"Kono\"},\"jdRD35YwbRH\":{\"name\":\"Sputum smear microscopy test\"},\"PMa2VCrupOd\":{\"code\":\"OU_211212\",\"name\":\"Kambia\"},\"2022\":{\"name\":\"2022\"},\"bL4ooGhyHRQ\":{\"code\":\"OU_260377\",\"name\":\"Pujehun\"},\"O6uvpzGd5pu\":{\"code\":\"OU_264\",\"name\":\"Bo\"},\"kJq2mPyFEHo\":{\"code\":\"OU_222616\",\"name\":\"Kenema\"},\"ZkbAXlQUYJG\":{\"name\":\"TB visit\"},\"wjP19dkFeIk\":{\"name\":\"District\"},\"fdc6uOvgoji\":{\"code\":\"OU_193190\",\"name\":\"Bombali\"},\"at6UHUQatSo\":{\"code\":\"OU_278310\",\"name\":\"Western Area\"},\"EPEcjy3FWmI\":{\"name\":\"Lab monitoring\"},\"pe\":{},\"ur1Edk5Oe2n\":{\"name\":\"TB program\"},\"ZkbAXlQUYJG.ou\":{\"name\":\"Organisation unit\"},\"lc3eMKXaEfw\":{\"code\":\"OU_197385\",\"name\":\"Bonthe\"},\"qhqAxPSTUXp\":{\"code\":\"OU_226213\",\"name\":\"Koinadugu\"},\"jmIPBj66vD6\":{\"code\":\"OU_246990\",\"name\":\"Moyamba\"}},\"dimensions\":{\"pe\":[],\"ZkbAXlQUYJG.ou\":[\"O6uvpzGd5pu\",\"fdc6uOvgoji\",\"lc3eMKXaEfw\",\"jUb8gELQApl\",\"PMa2VCrupOd\",\"kJq2mPyFEHo\",\"qhqAxPSTUXp\",\"Vth0fbpFcsO\",\"jmIPBj66vD6\",\"TEQlaapDQoK\",\"bL4ooGhyHRQ\",\"eIQbndfxQMb\",\"at6UHUQatSo\"]}}"; + String actualMetaData = new JSONObject((Map) response.extract("metaData")).toString(); + assertEquals(expectedMetaData, actualMetaData, false); + + // 4. Validate Headers By Name (conditionally checking PostGIS headers). + validateHeaderPropertiesByName( + response, actualHeaders, "psi", "Event", "TEXT", "java.lang.String", false, true); + validateHeaderPropertiesByName( + response, actualHeaders, "ps", "Program stage", "TEXT", "java.lang.String", false, true); + validateHeaderPropertiesByName( + response, + actualHeaders, + "eventdate", + "Event date", + "DATETIME", + "java.time.LocalDateTime", + false, + true); + validateHeaderPropertiesByName( + response, + actualHeaders, + "createdbydisplayname", + "Created by", + "TEXT", + "java.lang.String", + false, + true); + validateHeaderPropertiesByName( + response, + actualHeaders, + "lastupdatedbydisplayname", + "Last updated by", + "TEXT", + "java.lang.String", + false, + true); + validateHeaderPropertiesByName( + response, + actualHeaders, + "lastupdated", + "Last updated on", + "DATETIME", + "java.time.LocalDateTime", + false, + true); + validateHeaderPropertiesByName( + response, + actualHeaders, + "created", + "Created on", + "DATETIME", + "java.time.LocalDateTime", + false, + true); + validateHeaderPropertiesByName( + response, + actualHeaders, + "completed", + "Completed on", + "DATETIME", + "java.time.LocalDateTime", + false, + true); + validateHeaderPropertiesByName( + response, + actualHeaders, + "scheduleddate", + "Scheduled date", + "DATETIME", + "java.time.LocalDateTime", + false, + true); + validateHeaderPropertiesByName( + response, + actualHeaders, + "enrollmentdate", + "Start of treatment date", + "DATETIME", + "java.time.LocalDateTime", + false, + true); + validateHeaderPropertiesByName( + response, + actualHeaders, + "incidentdate", + "Start of treatment date", + "DATETIME", + "java.time.LocalDateTime", + false, + true); + validateHeaderPropertiesByName( + response, actualHeaders, "tei", "Tracked entity", "TEXT", "java.lang.String", false, true); + validateHeaderPropertiesByName( + response, actualHeaders, "pi", "Program instance", "TEXT", "java.lang.String", false, true); + if (expectPostgis) { + validateHeaderPropertiesByName( + response, actualHeaders, "geometry", "Geometry", "TEXT", "java.lang.String", false, true); + + validateHeaderPropertiesByName( + response, + actualHeaders, + "enrollmentgeometry", + "Enrollment geometry", + "TEXT", + "java.lang.String", + false, + true); + validateHeaderPropertiesByName( + response, + actualHeaders, + "longitude", + "Longitude", + "NUMBER", + "java.lang.Double", + false, + true); + validateHeaderPropertiesByName( + response, + actualHeaders, + "latitude", + "Latitude", + "NUMBER", + "java.lang.Double", + false, + true); + } + validateHeaderPropertiesByName( + response, + actualHeaders, + "ouname", + "Organisation unit name", + "TEXT", + "java.lang.String", + false, + true); + validateHeaderPropertiesByName( + response, + actualHeaders, + "ounamehierarchy", + "Organisation unit name hierarchy", + "TEXT", + "java.lang.String", + false, + true); + validateHeaderPropertiesByName( + response, + actualHeaders, + "oucode", + "Organisation unit code", + "TEXT", + "java.lang.String", + false, + true); + validateHeaderPropertiesByName( + response, + actualHeaders, + "programstatus", + "Program status", + "TEXT", + "java.lang.String", + false, + true); + validateHeaderPropertiesByName( + response, + actualHeaders, + "eventstatus", + "Event status", + "TEXT", + "java.lang.String", + false, + true); + validateHeaderPropertiesByName( + response, + actualHeaders, + "ZkbAXlQUYJG.ou", + "Organisation unit", + "ORGANISATION_UNIT", + "org.hisp.dhis.organisationunit.OrganisationUnit", + false, + true); + + // Assert PostGIS-specific headers existence based on 'expectPostgis' flag + if (expectPostgis) { + validateHeaderExistence(actualHeaders, "geometry", true); + validateHeaderExistence(actualHeaders, "longitude", true); + validateHeaderExistence(actualHeaders, "latitude", true); + } else { + validateHeaderExistence(actualHeaders, "geometry", false); + validateHeaderExistence(actualHeaders, "longitude", false); + validateHeaderExistence(actualHeaders, "latitude", false); + } + + // rowContext not found or empty in the response, skipping assertions. + + // 7. Assert row values by name at specific indices (sorted results). + // Validate selected values for row index 0 + validateRowValueByName(response, actualHeaders, 0, "psi", "IQCiAZs7PrK"); + validateRowValueByName(response, actualHeaders, 0, "ZkbAXlQUYJG.ou", "O6uvpzGd5pu"); + validateRowValueByName(response, actualHeaders, 0, "enrollmentdate", "2021-05-14 12:35:24.03"); + validateRowValueByName(response, actualHeaders, 0, "incidentdate", "2021-04-25 12:35:24.03"); + validateRowValueByName(response, actualHeaders, 0, "tei", "LxMVYhJm3Jp"); + validateRowValueByName(response, actualHeaders, 0, "pi", "awZ5RHoJin5"); + validateRowValueByName(response, actualHeaders, 0, "ouname", "Ngelehun CHC"); + validateRowValueByName(response, actualHeaders, 0, "programstatus", "ACTIVE"); + + // Validate selected values for row index 1 + validateRowValueByName(response, actualHeaders, 1, "psi", "La2PAKKx3it"); + validateRowValueByName(response, actualHeaders, 1, "ZkbAXlQUYJG.ou", "O6uvpzGd5pu"); + validateRowValueByName(response, actualHeaders, 1, "enrollmentdate", "2022-08-11 12:32:30.524"); + validateRowValueByName(response, actualHeaders, 1, "incidentdate", "2022-08-05 02:00:00.0"); + validateRowValueByName(response, actualHeaders, 1, "tei", "pUK3xmXayQ5"); + validateRowValueByName(response, actualHeaders, 1, "pi", "hXECENVui3x"); + validateRowValueByName(response, actualHeaders, 1, "ouname", "Ngelehun CHC"); + validateRowValueByName(response, actualHeaders, 1, "programstatus", "ACTIVE"); + + // Validate selected values for row index 2 + validateRowValueByName(response, actualHeaders, 2, "psi", "qgaHGxGEI56"); + validateRowValueByName(response, actualHeaders, 2, "ZkbAXlQUYJG.ou", "O6uvpzGd5pu"); + validateRowValueByName(response, actualHeaders, 2, "enrollmentdate", "2023-01-01 01:00:00.0"); + validateRowValueByName(response, actualHeaders, 2, "incidentdate", "2023-01-01 01:00:00.0"); + validateRowValueByName(response, actualHeaders, 2, "tei", "fSofnQR6lAU"); + validateRowValueByName(response, actualHeaders, 2, "pi", "fMCNMupsPrg"); + validateRowValueByName(response, actualHeaders, 2, "ouname", "Ngelehun CHC"); + validateRowValueByName(response, actualHeaders, 2, "programstatus", "CANCELLED"); + + // Validate selected values for row index 3 + validateRowValueByName(response, actualHeaders, 3, "psi", "BijwU5PwIMh"); + validateRowValueByName(response, actualHeaders, 3, "ZkbAXlQUYJG.ou", "O6uvpzGd5pu"); + validateRowValueByName(response, actualHeaders, 3, "enrollmentdate", "2023-01-15 01:00:00.0"); + validateRowValueByName(response, actualHeaders, 3, "incidentdate", "2023-01-15 01:00:00.0"); + validateRowValueByName(response, actualHeaders, 3, "tei", "fSofnQR6lAU"); + validateRowValueByName(response, actualHeaders, 3, "pi", "czKU08gniYG"); + validateRowValueByName(response, actualHeaders, 3, "ouname", "Ngelehun CHC"); + validateRowValueByName(response, actualHeaders, 3, "programstatus", "ACTIVE"); + } } diff --git a/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/generator/scenarios/event-aggregated.json b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/generator/scenarios/event-aggregated.json index 63ee13cb1cdd..7f1532a40536 100644 --- a/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/generator/scenarios/event-aggregated.json +++ b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/generator/scenarios/event-aggregated.json @@ -98,6 +98,13 @@ "min": 43 } }, + { + "name": "stageAndOuLevelWithBoundary", + "query": "/api/analytics/events/aggregate/ur1Edk5Oe2n.json?displayProperty=NAME&sortOrder=desc&totalPages=false&outputType=EVENT&dimension=pe:2022&dimension=ZkbAXlQUYJG.ou:LEVEL-wjP19dkFeIk;ImspTQPwCqd", + "version": { + "min": 44 + } + }, { "name": "stageAndOuMultipleOus", "query": "/api/analytics/events/aggregate/ur1Edk5Oe2n.json?displayProperty=NAME&sortOrder=desc&totalPages=false&outputType=EVENT&dimension=pe:LAST_5_YEARS&dimension=ZkbAXlQUYJG.ou:O6uvpzGd5pu;DiszpKrYNg8&relativePeriodDate=2023-07-01", diff --git a/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/generator/scenarios/event-query.json b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/generator/scenarios/event-query.json index fa82f54aaa1f..83e868772979 100644 --- a/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/generator/scenarios/event-query.json +++ b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/analytics/generator/scenarios/event-query.json @@ -154,6 +154,13 @@ "min": 43 } }, + { + "name": "stageAndOuLevelWithBoundary", + "query": "/api/analytics/events/query/ur1Edk5Oe2n.json?displayProperty=NAME&outputType=EVENT&pageSize=100&page=1&dimension=ZkbAXlQUYJG.ou:LEVEL-wjP19dkFeIk;ImspTQPwCqd&dimension=pe:2022&desc=eventdate,lastupdated", + "version": { + "min": 44 + } + }, { "name": "stageAndScheduledDate", "query": "/api/analytics/events/query/ur1Edk5Oe2n.json?displayProperty=NAME&outputType=EVENT&pageSize=100&page=1&dimension=ZkbAXlQUYJG.SCHEDULED_DATE:202208&desc=eventdate,lastupdated",