diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/category/CategoryCombo.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/category/CategoryCombo.java index 0660842aede8..1e44fb1d2afb 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/category/CategoryCombo.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/category/CategoryCombo.java @@ -34,10 +34,12 @@ import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty; import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement; import java.util.ArrayList; +import java.util.Collection; import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.stream.Collectors; +import javax.annotation.Nonnull; import org.hisp.dhis.common.BaseIdentifiableObject; import org.hisp.dhis.common.CombinationGenerator; import org.hisp.dhis.common.DataDimensionType; @@ -223,6 +225,18 @@ public void removeAllCategories() { categories.clear(); } + public void addCategoryOptionCombo(@Nonnull CategoryOptionCombo coc) { + this.getOptionCombos().add(coc); + } + + public void removeCategoryOptionCombo(@Nonnull CategoryOptionCombo coc) { + this.getOptionCombos().remove(coc); + } + + public void removeCategoryOptionCombos(@Nonnull Collection cocs) { + cocs.forEach(this::removeCategoryOptionCombo); + } + // ------------------------------------------------------------------------- // Getters and setters // ------------------------------------------------------------------------- diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/category/CategoryComboStore.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/category/CategoryComboStore.java index c21d38bc9b40..1eb0aa76c45d 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/category/CategoryComboStore.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/category/CategoryComboStore.java @@ -27,13 +27,25 @@ */ package org.hisp.dhis.category; +import java.util.Collection; import java.util.List; +import javax.annotation.Nonnull; import org.hisp.dhis.common.DataDimensionType; import org.hisp.dhis.common.IdentifiableObjectStore; +import org.hisp.dhis.common.UID; /** * @author Lars Helge Overland */ public interface CategoryComboStore extends IdentifiableObjectStore { List getCategoryCombosByDimensionType(DataDimensionType dataDimensionType); + + /** + * Retrieve all {@link CategoryCombo}s with {@link CategoryOptionCombo} {@link UID}s + * + * @param uids {@link CategoryOptionCombo} {@link UID}s + * @return {@link CategoryCombo}s with references to {@link CategoryOptionCombo} {@link UID}s + * passed in + */ + List getByCategoryOptionCombo(@Nonnull Collection uids); } diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/category/CategoryOptionStore.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/category/CategoryOptionStore.java index 48e546efa7cc..63097c630f88 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/category/CategoryOptionStore.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/category/CategoryOptionStore.java @@ -27,8 +27,10 @@ */ package org.hisp.dhis.category; +import java.util.Collection; import java.util.List; import javax.annotation.CheckForNull; +import javax.annotation.Nonnull; import org.hisp.dhis.common.IdentifiableObjectStore; import org.hisp.dhis.common.UID; import org.hisp.dhis.user.UserDetails; @@ -47,4 +49,13 @@ public interface CategoryOptionStore extends IdentifiableObjectStore getCategoryOptions(Category category); List getDataWriteCategoryOptions(Category category, UserDetails userDetails); + + /** + * Retrieve all {@link CategoryOption}s with {@link CategoryOptionCombo} {@link UID}s + * + * @param uids {@link CategoryOptionCombo} {@link UID}s + * @return {@link CategoryOption}s with references to {@link CategoryOptionCombo} {@link UID}s + * passed in + */ + List getByCategoryOptionCombo(@Nonnull Collection uids); } diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/category/CategoryService.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/category/CategoryService.java index 1e1609a6b141..260961bd8d79 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/category/CategoryService.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/category/CategoryService.java @@ -30,6 +30,7 @@ import java.util.Collection; import java.util.List; import java.util.Set; +import javax.annotation.Nonnull; import org.apache.commons.collections4.SetValuedMap; import org.hisp.dhis.common.IdScheme; import org.hisp.dhis.common.UID; @@ -453,7 +454,15 @@ CategoryOptionCombo getCategoryOptionCombo( * @return categoryOptionCombos with refs to categoryOptions */ List getCategoryOptionCombosByCategoryOption( - Collection categoryOptions); + @Nonnull Collection categoryOptions); + + /** + * Retrieves all CategoryOptionCombos by {@link UID}. + * + * @param uids {@link UID}s to search for + * @return categoryOptionCombos with refs to {@link UID}s + */ + List getCategoryOptionCombosByUid(@Nonnull Collection uids); // ------------------------------------------------------------------------- // DataElementOperand diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/dataapproval/DataApprovalAuditStore.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/dataapproval/DataApprovalAuditStore.java index 1a125f3881a5..625d909a1ac7 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/dataapproval/DataApprovalAuditStore.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/dataapproval/DataApprovalAuditStore.java @@ -28,6 +28,7 @@ package org.hisp.dhis.dataapproval; import java.util.List; +import org.hisp.dhis.category.CategoryOptionCombo; import org.hisp.dhis.common.GenericStore; import org.hisp.dhis.organisationunit.OrganisationUnit; @@ -46,6 +47,13 @@ public interface DataApprovalAuditStore extends GenericStore */ void deleteDataApprovalAudits(OrganisationUnit organisationUnit); + /** + * Deletes DataApprovalAudits for the given category option combo. + * + * @param coc the category option combo + */ + void deleteDataApprovalAudits(CategoryOptionCombo coc); + /** * Returns DataApprovalAudit objects for query parameters. * diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/dataapproval/DataApprovalStore.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/dataapproval/DataApprovalStore.java index ff845f2ee79c..fb84b7bad794 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/dataapproval/DataApprovalStore.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/dataapproval/DataApprovalStore.java @@ -31,8 +31,10 @@ import java.util.List; import java.util.Map; import java.util.Set; +import javax.annotation.Nonnull; import org.hisp.dhis.category.CategoryCombo; import org.hisp.dhis.category.CategoryOptionCombo; +import org.hisp.dhis.common.UID; import org.hisp.dhis.organisationunit.OrganisationUnit; import org.hisp.dhis.period.Period; @@ -162,4 +164,19 @@ List getDataApprovalStatuses( Set attributeOptionCombos, List userApprovalLevels, Map levelMap); + + /** + * Retrieve all {@link DataApproval}s with {@link CategoryOptionCombo} {@link UID}s + * + * @param uids {@link CategoryOptionCombo} {@link UID}s + * @return {@link DataApproval}s with {@link CategoryOptionCombo} {@link UID}s passed in + */ + List getByCategoryOptionCombo(@Nonnull Collection uids); + + /** + * Delete all {@link DataApproval}s with references to {@link CategoryOptionCombo} {@link UID}s + * + * @param uids {@link CategoryOptionCombo} {@link UID}s + */ + void deleteByCategoryOptionCombo(@Nonnull Collection uids); } diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/datadimensionitem/DataDimensionItemStore.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/datadimensionitem/DataDimensionItemStore.java index bbbb547add92..f7636fae3bb7 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/datadimensionitem/DataDimensionItemStore.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/datadimensionitem/DataDimensionItemStore.java @@ -27,6 +27,7 @@ */ package org.hisp.dhis.datadimensionitem; +import java.util.Collection; import java.util.List; import org.hisp.dhis.common.DataDimensionItem; import org.hisp.dhis.common.GenericStore; @@ -47,4 +48,14 @@ public interface DataDimensionItemStore extends GenericStore List getIndicatorDataDimensionItems(List indicators); List getDataElementDataDimensionItems(List dataElements); + + /** + * Update the entities with refs to the category option combo ids passed in, with the new category + * option combo id passed in. + * + * @param cocIds category option combo ids to be used to update linked data dimension items + * @param newCocId new category option combo id to use + * @return number of entities updated + */ + int updateDeoCategoryOptionCombo(Collection cocIds, long newCocId); } diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/dataelement/DataElementOperandStore.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/dataelement/DataElementOperandStore.java index 554a7283bf92..1014f8139676 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/dataelement/DataElementOperandStore.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/dataelement/DataElementOperandStore.java @@ -29,7 +29,10 @@ import java.util.Collection; import java.util.List; +import javax.annotation.Nonnull; +import org.hisp.dhis.category.CategoryOptionCombo; import org.hisp.dhis.common.IdentifiableObjectStore; +import org.hisp.dhis.common.UID; /** * @author Morten Olav Hansen @@ -37,5 +40,20 @@ public interface DataElementOperandStore extends IdentifiableObjectStore { String ID = DataElementOperand.class.getName(); + /** + * Retrieve all {@link DataElementOperand}s with {@link DataElement}s + * + * @param dataElements {@link DataElement}s + * @return {@link DataElementOperand}s with references to {@link DataElement}s passed in + */ List getByDataElement(Collection dataElements); + + /** + * Retrieve all {@link DataElementOperand}s with {@link CategoryOptionCombo} {@link UID}s + * + * @param uids {@link CategoryOptionCombo} {@link UID}s + * @return {@link DataElementOperand}s with references to {@link CategoryOptionCombo} {@link UID}s + * passed in + */ + List getByCategoryOptionCombo(@Nonnull Collection uids); } diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/dataset/CompleteDataSetRegistration.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/dataset/CompleteDataSetRegistration.java index c24d9e4f3fb0..bc1624f4dd9d 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/dataset/CompleteDataSetRegistration.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/dataset/CompleteDataSetRegistration.java @@ -35,6 +35,7 @@ import com.google.common.base.MoreObjects; import java.io.Serializable; import java.util.Date; +import javax.annotation.Nonnull; import org.hisp.dhis.category.CategoryOptionCombo; import org.hisp.dhis.common.BaseIdentifiableObject; import org.hisp.dhis.common.DxfNamespaces; @@ -323,4 +324,28 @@ public String toString() { .add("isCompleted", completed) .toString(); } + + /** + * Creates a copy of the passed in CompleteDataSetRegistration, using all old values except for + * attributeOptionCombo, which uses the param attributeOptionCombo passed in. + * + * @param old old CompleteDataSetRegistration to use values from + * @param attributeOptionCombo attributeOptionCombo to use as new value in new + * CompleteDataSetRegistration + * @return copy of old CompleteDataSetRegistration except with a new attributeOptionCombo + */ + public static CompleteDataSetRegistration copyWithNewAttributeOptionCombo( + @Nonnull CompleteDataSetRegistration old, @Nonnull CategoryOptionCombo attributeOptionCombo) { + CompleteDataSetRegistration newCopy = new CompleteDataSetRegistration(); + newCopy.setDataSet(old.getDataSet()); + newCopy.setPeriod(old.getPeriod()); + newCopy.setSource(old.getSource()); + newCopy.setAttributeOptionCombo(attributeOptionCombo); + newCopy.setDate(old.getDate()); + newCopy.setStoredBy(old.getStoredBy()); + newCopy.setLastUpdated(old.getLastUpdated()); + newCopy.setCompleted(old.getCompleted()); + newCopy.setPeriodName(old.getPeriodName()); + return newCopy; + } } diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/dataset/CompleteDataSetRegistrationStore.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/dataset/CompleteDataSetRegistrationStore.java index 91e2340e4270..d7b72adbf917 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/dataset/CompleteDataSetRegistrationStore.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/dataset/CompleteDataSetRegistrationStore.java @@ -27,9 +27,12 @@ */ package org.hisp.dhis.dataset; +import java.util.Collection; import java.util.Date; import java.util.List; +import javax.annotation.Nonnull; import org.hisp.dhis.category.CategoryOptionCombo; +import org.hisp.dhis.common.UID; import org.hisp.dhis.organisationunit.OrganisationUnit; import org.hisp.dhis.period.Period; @@ -44,6 +47,13 @@ public interface CompleteDataSetRegistrationStore { */ void saveCompleteDataSetRegistration(CompleteDataSetRegistration registration); + /** + * Saves a CompleteDataSetRegistration without updating its lastUpdated value + * + * @param registration reg to update + */ + void saveWithoutUpdatingLastUpdated(@Nonnull CompleteDataSetRegistration registration); + /** * Updates a CompleteDataSetRegistration. * @@ -102,4 +112,20 @@ CompleteDataSetRegistration getCompleteDataSetRegistration( * @return the number of completed DataSets. */ int getCompleteDataSetCountLastUpdatedAfter(Date lastUpdated); + + /** + * Retrieve all {@link CompleteDataSetRegistration}s with {@link CategoryOptionCombo} {@link UID}s + * + * @param uids {@link CategoryOptionCombo} {@link UID}s + * @return {@link CompleteDataSetRegistration}s with references to {@link CategoryOptionCombo} + * {@link UID}s passed in + */ + List getAllByCategoryOptionCombo(@Nonnull Collection uids); + + /** + * Delete all {@link CompleteDataSetRegistration}s with references to {@link CategoryOptionCombo}s + * + * @param cocs {@link CategoryOptionCombo}s + */ + void deleteByCategoryOptionCombo(@Nonnull Collection cocs); } diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/datavalue/DataValue.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/datavalue/DataValue.java index 337f1d3689b3..8d10d1dc4ffb 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/datavalue/DataValue.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/datavalue/DataValue.java @@ -272,34 +272,6 @@ public void mergeWith(DataValue other) { this.deleted = other.isDeleted(); } - /** - * Method that creates a new {@link DataValue}. All the old values are used from the supplied old - * {@link DataValue} except for the {@link DataElement} field, which uses the supplied {@link - * DataElement}. - * - * @param oldDv old {@link DataValue} whose values will be used in the new {@link DataValue} - * @param newDataElement {@link DataElement} to be used in the new {@link DataValue} - * @return new {@link DataValue} - */ - public static DataValue dataValueWithNewDataElement(DataValue oldDv, DataElement newDataElement) { - DataValue newValue = - DataValue.builder() - .dataElement(newDataElement) - .period(oldDv.getPeriod()) - .source(oldDv.getSource()) - .categoryOptionCombo(oldDv.getCategoryOptionCombo()) - .attributeOptionCombo(oldDv.getAttributeOptionCombo()) - .value(oldDv.getValue()) - .storedBy(oldDv.getStoredBy()) - .lastUpdated(oldDv.getLastUpdated()) - .comment(oldDv.getComment()) - .followup(oldDv.isFollowup()) - .deleted(oldDv.isDeleted()) - .build(); - newValue.setCreated(oldDv.getCreated()); - return newValue; - } - // ------------------------------------------------------------------------- // hashCode and equals // ------------------------------------------------------------------------- diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/datavalue/DataValueAuditStore.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/datavalue/DataValueAuditStore.java index 8ab537549896..7761b4af5990 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/datavalue/DataValueAuditStore.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/datavalue/DataValueAuditStore.java @@ -28,6 +28,8 @@ package org.hisp.dhis.datavalue; import java.util.List; +import javax.annotation.Nonnull; +import org.hisp.dhis.category.CategoryOptionCombo; import org.hisp.dhis.dataelement.DataElement; import org.hisp.dhis.organisationunit.OrganisationUnit; @@ -68,6 +70,14 @@ public interface DataValueAuditStore { */ void deleteDataValueAudits(DataElement dataElement); + /** + * Deletes all data value audits for the given category option combo. Both properties: + * categoryOptionCombo & attributeOptionCombo are checked for a match. + * + * @param categoryOptionCombo the categoryOptionCombo. + */ + void deleteDataValueAudits(@Nonnull CategoryOptionCombo categoryOptionCombo); + /** * Returns data value audits for the given query. * diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/datavalue/DataValueStore.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/datavalue/DataValueStore.java index 61bd3550f9a2..74433a5c45f9 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/datavalue/DataValueStore.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/datavalue/DataValueStore.java @@ -27,9 +27,11 @@ */ package org.hisp.dhis.datavalue; +import java.util.Collection; import java.util.Date; import java.util.List; import java.util.concurrent.TimeUnit; +import javax.annotation.Nonnull; import org.hisp.dhis.category.CategoryCombo; import org.hisp.dhis.category.CategoryOptionCombo; import org.hisp.dhis.common.UID; @@ -92,6 +94,29 @@ public interface DataValueStore { */ void deleteDataValues(DataElement dataElement); + /** + * Deletes all data values for the given data element. + * + * @param dataElement the dataElement. + */ + void deleteDataValues(@Nonnull Collection dataElement); + + /** + * Deletes all data values for the given category option combos. + * + * @param categoryOptionCombos the categoryOptionCombos. + */ + void deleteDataValuesByCategoryOptionCombo( + @Nonnull Collection categoryOptionCombos); + + /** + * Deletes all data values for the given attribute option combos. + * + * @param attributeOptionCombos the attributeOptionCombos. + */ + void deleteDataValuesByAttributeOptionCombo( + @Nonnull Collection attributeOptionCombos); + void deleteDataValue(DataValue dataValue); /** @@ -165,6 +190,12 @@ DataValue getDataValue( */ List getDeflatedDataValues(DataExportParams params); + /** + * Retrieve all {@link DataValue}s with references to {@link DataElement}s + * + * @param dataElements {@link DataElement}s + * @return {@link DataValue}s with references to {@link DataElement}s passed in + */ List getAllDataValuesByDataElement(List dataElements); /** @@ -193,4 +224,54 @@ DataValue getDataValue( * @return true, if any values exist, otherwise false */ boolean dataValueExistsForDataElement(String uid); + + List getAllDataValuesByCatOptCombo(@Nonnull Collection uids); + + List getAllDataValuesByAttrOptCombo(@Nonnull Collection uids); + + /** + * SQL for handling merging {@link DataValue}s. There may be multiple potential {@link DataValue} + * duplicates. Duplicate {@link DataValue}s with the latest {@link DataValue#lastUpdated} values + * are kept, the rest are deleted. Only one of these entries can exist due to the composite key + * constraint.
+ * The 3 execution paths are: + * + *

1. If the source {@link DataValue} is not a duplicate, it simply gets its {@link + * DataValue#categoryOptionCombo} updated to that of the target. + * + *

2. If the source {@link DataValue} is a duplicate and has an earlier {@link + * DataValue#lastUpdated} value, it is deleted. + * + *

3. If the source {@link DataValue} is a duplicate and has a later {@link + * DataValue#lastUpdated} value, the target {@link DataValue} is deleted. The source is kept and + * has its {@link DataValue#categoryOptionCombo} updated to that of the target. + * + * @param target target {@link CategoryOptionCombo} + * @param sources source {@link CategoryOptionCombo}s + */ + void mergeDataValuesWithCategoryOptionCombos( + @Nonnull CategoryOptionCombo target, @Nonnull Collection sources); + + /** + * SQL for handling merging {@link DataValue}s. There may be multiple potential {@link DataValue} + * duplicates. Duplicate {@link DataValue}s with the latest {@link DataValue#lastUpdated} values + * are kept, the rest are deleted. Only one of these entries can exist due to the composite key + * constraint.
+ * The 3 execution paths are: + * + *

1. If the source {@link DataValue} is not a duplicate, it simply gets its {@link + * DataValue#attributeOptionCombo} updated to that of the target. + * + *

2. If the source {@link DataValue} is a duplicate and has an earlier {@link + * DataValue#lastUpdated} value, it is deleted. + * + *

3. If the source {@link DataValue} is a duplicate and has a later {@link + * DataValue#lastUpdated} value, the target {@link DataValue} is deleted. The source is kept and + * has its {@link DataValue#attributeOptionCombo} updated to that of the target. + * + * @param target target {@link CategoryOptionCombo} + * @param sources source {@link CategoryOptionCombo}s + */ + void mergeDataValuesWithAttributeOptionCombos( + @Nonnull CategoryOptionCombo target, @Nonnull Collection sources); } diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/maintenance/MaintenanceStore.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/maintenance/MaintenanceStore.java index 895414497b93..bff0a8527785 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/maintenance/MaintenanceStore.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/maintenance/MaintenanceStore.java @@ -27,6 +27,8 @@ */ package org.hisp.dhis.maintenance; +import java.util.List; + /** * @author Lars Helge Overland */ @@ -55,6 +57,8 @@ public interface MaintenanceStore { */ int deleteSoftDeletedEvents(); + int hardDeleteEvents(List eventsToDelete, String eventSelect, String eventDeleteQuery); + /** * Permanently deletes relationships which have been soft deleted, i.e. relationships where the * deleted property is true. @@ -81,4 +85,6 @@ public interface MaintenanceStore { /** Deletes periods which are not associated with any other table. */ void prunePeriods(); + + List getDeletionEntities(String entitySql); } diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/merge/MergeType.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/merge/MergeType.java index a19697ace91b..ea2ac54ea215 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/merge/MergeType.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/merge/MergeType.java @@ -29,6 +29,7 @@ import com.fasterxml.jackson.annotation.JsonValue; import org.hisp.dhis.category.CategoryOption; +import org.hisp.dhis.category.CategoryOptionCombo; import org.hisp.dhis.common.IdentifiableObject; import org.hisp.dhis.dataelement.DataElement; import org.hisp.dhis.indicator.Indicator; @@ -43,7 +44,8 @@ public enum MergeType { INDICATOR_TYPE(IndicatorType.class), INDICATOR(Indicator.class), DATA_ELEMENT(DataElement.class), - CATEGORY_OPTION(CategoryOption.class); + CATEGORY_OPTION(CategoryOption.class), + CATEGORY_OPTION_COMBO(CategoryOptionCombo.class); private final Class clazz; private final String name; diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/minmax/MinMaxDataElementStore.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/minmax/MinMaxDataElementStore.java index db1aa923f63f..038dbb572554 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/minmax/MinMaxDataElementStore.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/minmax/MinMaxDataElementStore.java @@ -29,8 +29,10 @@ import java.util.Collection; import java.util.List; +import javax.annotation.Nonnull; import org.hisp.dhis.category.CategoryOptionCombo; import org.hisp.dhis.common.GenericStore; +import org.hisp.dhis.common.UID; import org.hisp.dhis.dataelement.DataElement; import org.hisp.dhis.organisationunit.OrganisationUnit; @@ -58,4 +60,14 @@ MinMaxDataElement get( void delete(Collection dataElements, OrganisationUnit parent); List getByDataElement(Collection dataElements); + + /** + * Retrieve all {@link MinMaxDataElement}s with references to {@link CategoryOptionCombo} {@link + * UID}s + * + * @param uids {@link CategoryOptionCombo} {@link UID}s + * @return {@link MinMaxDataElement}s with references to {@link CategoryOptionCombo} {@link UID} + * passed in + */ + List getByCategoryOptionCombo(@Nonnull Collection uids); } diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/predictor/PredictorStore.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/predictor/PredictorStore.java index 848f5b73fc5e..e505d07cfb40 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/predictor/PredictorStore.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/predictor/PredictorStore.java @@ -30,7 +30,9 @@ import java.util.Collection; import java.util.List; import javax.annotation.Nonnull; +import org.hisp.dhis.category.CategoryOptionCombo; import org.hisp.dhis.common.IdentifiableObjectStore; +import org.hisp.dhis.common.UID; import org.hisp.dhis.dataelement.DataElement; /** @@ -45,4 +47,12 @@ public interface PredictorStore extends IdentifiableObjectStore { List getAllWithSampleSkipTestContainingDataElement( @Nonnull List dataElementUids); + + /** + * Retrieve all {@link Predictor}s with references to {@link CategoryOptionCombo} {@link UID}s + * + * @param uids {@link CategoryOptionCombo} {@link UID}s + * @return {@link Predictor}s with references to {@link CategoryOptionCombo} {@link UID} passed in + */ + List getByCategoryOptionCombo(@Nonnull Collection uids); } diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/program/EventStore.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/program/EventStore.java index 051747d98536..841ac8c02e39 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/program/EventStore.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/program/EventStore.java @@ -28,6 +28,8 @@ package org.hisp.dhis.program; import java.util.List; +import java.util.Set; +import org.hisp.dhis.category.CategoryOptionCombo; import org.hisp.dhis.common.IdentifiableObjectStore; /** @@ -36,4 +38,13 @@ public interface EventStore extends IdentifiableObjectStore { List getAllWithEventDataValuesRootKeysContainingAnyOf(List searchStrings); + + /** + * Updates all {@link Event}s with references to {@link CategoryOptionCombo}s, to use the coc + * reference. + * + * @param cocs {@link CategoryOptionCombo}s to update + * @param coc {@link CategoryOptionCombo} to use as the new value + */ + void setAttributeOptionCombo(Set cocs, long coc); } diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/security/Authorities.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/security/Authorities.java index 1ad8ebf72978..e6d4666c49fb 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/security/Authorities.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/security/Authorities.java @@ -87,6 +87,7 @@ public enum Authorities { F_INDICATOR_MERGE, F_DATA_ELEMENT_MERGE, F_CATEGORY_OPTION_MERGE, + F_CATEGORY_OPTION_COMBO_MERGE, F_INSERT_CUSTOM_JS_CSS, F_VIEW_UNAPPROVED_DATA, F_USER_VIEW, diff --git a/dhis-2/dhis-services/dhis-service-administration/pom.xml b/dhis-2/dhis-services/dhis-service-administration/pom.xml index f2100f1a647e..fe03be0f7a1d 100644 --- a/dhis-2/dhis-services/dhis-service-administration/pom.xml +++ b/dhis-2/dhis-services/dhis-service-administration/pom.xml @@ -131,7 +131,7 @@ com.networknt json-schema-validator - 1.5.4 + 1.5.5 org.apache.commons diff --git a/dhis-2/dhis-services/dhis-service-administration/src/main/java/org/hisp/dhis/maintenance/jdbc/JdbcMaintenanceStore.java b/dhis-2/dhis-services/dhis-service-administration/src/main/java/org/hisp/dhis/maintenance/jdbc/JdbcMaintenanceStore.java index b24dc776eccc..7b70b07553cc 100644 --- a/dhis-2/dhis-services/dhis-service-administration/src/main/java/org/hisp/dhis/maintenance/jdbc/JdbcMaintenanceStore.java +++ b/dhis-2/dhis-services/dhis-service-administration/src/main/java/org/hisp/dhis/maintenance/jdbc/JdbcMaintenanceStore.java @@ -101,6 +101,11 @@ public int deleteSoftDeletedEvents() { String eventSelect = "(select eventid from event where deleted is true)"; + return hardDeleteEvents(deletedEvents, eventSelect, "delete from event where deleted is true"); + } + + @Override + public int hardDeleteEvents(List eventsToDelete, String eventSelect, String eventDelete) { String pmSelect = "(select id from programmessage where eventid in " + eventSelect + " )"; /* @@ -126,15 +131,14 @@ public int deleteSoftDeletedEvents() { "delete from programmessage where eventid in " + eventSelect, "delete from programnotificationinstance where eventid in " + eventSelect, // finally delete the events - "delete from event where deleted is true" + eventDelete }; int result = jdbcTemplate.batchUpdate(sqlStmts)[sqlStmts.length - 1]; - if (result > 0 && !deletedEvents.isEmpty()) { - auditHardDeletedEntity(deletedEvents, Event.class); + if (result > 0 && !eventsToDelete.isEmpty()) { + auditHardDeletedEntity(eventsToDelete, Event.class); } - return result; } @@ -352,7 +356,8 @@ public void prunePeriods() { jdbcTemplate.batchUpdate(sql); } - private List getDeletionEntities(String entitySql) { + @Override + public List getDeletionEntities(String entitySql) { /* * Get all soft deleted entities before they are hard deleted from * database diff --git a/dhis-2/dhis-services/dhis-service-administration/src/main/java/org/hisp/dhis/merge/CommonDataMergeHandler.java b/dhis-2/dhis-services/dhis-service-administration/src/main/java/org/hisp/dhis/merge/CommonDataMergeHandler.java new file mode 100644 index 000000000000..03713a2e2b45 --- /dev/null +++ b/dhis-2/dhis-services/dhis-service-administration/src/main/java/org/hisp/dhis/merge/CommonDataMergeHandler.java @@ -0,0 +1,186 @@ +/* + * Copyright (c) 2004-2024, 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: + * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 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. + * Neither the name of the HISP project 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.merge; + +import jakarta.persistence.EntityManager; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.function.BiFunction; +import java.util.function.BiPredicate; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.stream.Collectors; +import javax.annotation.Nonnull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.hisp.dhis.category.CategoryOptionCombo; +import org.hisp.dhis.common.BaseIdentifiableObject; +import org.hisp.dhis.dataelement.DataElement; +import org.hisp.dhis.datavalue.DataValue; +import org.hisp.dhis.datavalue.DataValueStore; +import org.springframework.stereotype.Component; + +/** + * Common Merge handler for data entities. The merge operations here are shared by multiple merge + * use cases (e.g. CategoryOptionCombo & DataElement merge), hence the need for common handlers, to + * reuse code and avoid duplication. This keeps merges consistent, for better or for worse. + * + * @author david mackessy + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class CommonDataMergeHandler { + + private final DataValueStore dataValueStore; + private final EntityManager entityManager; + + /** + * Groups {@link DataValue}s into duplicates & non-duplicates. It uses the duplicate predicate + * param value to decide whether the {@link DataValue} is considered a duplicate or not. Once + * grouped, they are then passed on to be handled accordingly. + * + * @param merge {@link DataValueMergeParams} to perform merge + * @param {@link BaseIdentifiableObject} type + */ + public void handleDataValues( + @Nonnull DataValueMergeParams merge) { + Map> sourceDuplicateList = + merge.sourceDataValues.stream() + .collect( + Collectors.partitioningBy( + dv -> merge.dvDuplicatePredicate.test(dv, merge.targetDataValues))); + + if (!sourceDuplicateList.get(false).isEmpty()) + handleNonDuplicates(sourceDuplicateList.get(false), merge); + if (!sourceDuplicateList.get(true).isEmpty()) + handleDuplicates(sourceDuplicateList.get(true), merge); + } + + /** + * Handle merging duplicate {@link DataValue}s. There may be multiple potential {@link DataValue} + * duplicates. The {@link DataValue} with the latest `lastUpdated` value is filtered out, the rest + * are then deleted at the end of the process (We can only have one of these entries due to the + * composite key constraint). The filtered-out {@link DataValue} will be compared with the target + * {@link DataValue} lastUpdated date. + * + *

If the target date is later, no action is required. + * + *

If the source date is later, then A new {@link DataValue} will be created from the old + * {@link DataValue} values and it will use the target {@link DataElement} ref. This new {@link + * DataValue} will be saved to the database. This sequence is required as a {@link DataValue} has + * a composite primary key. This prohibits updating the ref in a source {@link DataValue}. The + * matching target {@link DataValue}s will then be deleted. + * + * @param sourceDataValueDuplicates {@link DataValue}s to merge + * @param dvMergeParams {@link DataValueMergeParams} + */ + private void handleDuplicates( + @Nonnull Collection sourceDataValueDuplicates, + @Nonnull DataValueMergeParams dvMergeParams) { + log.info( + "Handling " + + sourceDataValueDuplicates.size() + + " duplicate data values, keeping later lastUpdated value"); + + // Group Data values by key, so we can deal with each duplicate correctly + Map> sourceDataValuesGroupedByKey = + sourceDataValueDuplicates.stream() + .collect(Collectors.groupingBy(dvMergeParams.dataValueKey)); + + // Filter groups down to single DV with latest date + List filtered = + sourceDataValuesGroupedByKey.values().stream() + .map(ls -> Collections.max(ls, Comparator.comparing(DataValue::getLastUpdated))) + .toList(); + + for (DataValue source : filtered) { + DataValue matchingTargetDataValue = + dvMergeParams.targetDataValues.get(dvMergeParams.dataValueKey.apply(source)); + + if (matchingTargetDataValue.getLastUpdated().before(source.getLastUpdated())) { + dataValueStore.deleteDataValue(matchingTargetDataValue); + + // Detaching is required here as it's not possible to add a new DataValue with essentially + // the same composite primary key - Throws `NonUniqueObjectException: A different object + // with the same identifier value was already associated with the session` + entityManager.detach(matchingTargetDataValue); + DataValue copyWithNewRef = + dvMergeParams.newDataValueFromOld.apply(source, dvMergeParams.target); + dataValueStore.addDataValue(copyWithNewRef); + } + } + + // Delete the rest of the source data values after handling the last update duplicate + dvMergeParams.dvStoreDelete.accept(dvMergeParams.sources); + } + + /** + * Method to handle merging non-duplicate {@link DataValue}s. A new {@link DataValue} will be + * created from the old {@link DataValue} values, and it will use the target {@link + * CategoryOptionCombo} ref. This new {@link DataValue} will be saved to the database. This + * sequence is required as a {@link DataValue} has a composite primary key. This prohibits + * updating the ref in a source {@link DataValue}. + * + *

All source {@link DataValue}s will then be deleted. + * + * @param dataValues {@link DataValue}s to merge + * @param dvMergeParams {@link DataValueMergeParams} + */ + private void handleNonDuplicates( + @Nonnull List dataValues, @Nonnull DataValueMergeParams dvMergeParams) { + log.info( + "Handling " + + dataValues.size() + + " non duplicate data values. Add new DataValue entry (using target ref) and delete old source entry"); + + dataValues.forEach( + sourceDataValue -> { + DataValue copyWithNewCocRef = + dvMergeParams.newDataValueFromOld.apply(sourceDataValue, dvMergeParams.target); + dataValueStore.addDataValue(copyWithNewCocRef); + }); + + log.info("Deleting all data values referencing source CategoryOptionCombos"); + dvMergeParams.dvStoreDelete.accept(dvMergeParams.sources); + } + + public record DataValueMergeParams( + @Nonnull MergeRequest mergeRequest, + @Nonnull List sources, + @Nonnull T target, + @Nonnull List sourceDataValues, + @Nonnull Map targetDataValues, + @Nonnull Consumer> dvStoreDelete, + @Nonnull BiPredicate> dvDuplicatePredicate, + @Nonnull BiFunction newDataValueFromOld, + @Nonnull Function dataValueKey) {} +} diff --git a/dhis-2/dhis-services/dhis-service-administration/src/main/java/org/hisp/dhis/merge/category/option/CategoryOptionMergeService.java b/dhis-2/dhis-services/dhis-service-administration/src/main/java/org/hisp/dhis/merge/category/option/CategoryOptionMergeService.java index 3e2ccf59c655..5607511e3942 100644 --- a/dhis-2/dhis-services/dhis-service-administration/src/main/java/org/hisp/dhis/merge/category/option/CategoryOptionMergeService.java +++ b/dhis-2/dhis-services/dhis-service-administration/src/main/java/org/hisp/dhis/merge/category/option/CategoryOptionMergeService.java @@ -36,7 +36,6 @@ import org.hisp.dhis.category.CategoryOption; import org.hisp.dhis.category.CategoryService; import org.hisp.dhis.common.UID; -import org.hisp.dhis.dataelement.DataElement; import org.hisp.dhis.feedback.MergeReport; import org.hisp.dhis.merge.MergeParams; import org.hisp.dhis.merge.MergeRequest; @@ -109,7 +108,7 @@ private void initMergeHandlers() { } /** - * Functional interface representing a {@link DataElement} data merge operation. + * Functional interface representing a {@link CategoryOption} data merge operation. * * @author david mackessy */ diff --git a/dhis-2/dhis-services/dhis-service-administration/src/main/java/org/hisp/dhis/merge/category/optioncombo/CategoryOptionComboMergeService.java b/dhis-2/dhis-services/dhis-service-administration/src/main/java/org/hisp/dhis/merge/category/optioncombo/CategoryOptionComboMergeService.java new file mode 100644 index 000000000000..bbaa4928b09f --- /dev/null +++ b/dhis-2/dhis-services/dhis-service-administration/src/main/java/org/hisp/dhis/merge/category/optioncombo/CategoryOptionComboMergeService.java @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2004-2024, 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: + * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 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. + * Neither the name of the HISP project 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.merge.category.optioncombo; + +import jakarta.persistence.EntityManager; +import java.util.List; +import javax.annotation.Nonnull; +import javax.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.hisp.dhis.category.CategoryOptionCombo; +import org.hisp.dhis.category.CategoryService; +import org.hisp.dhis.feedback.ErrorCode; +import org.hisp.dhis.feedback.ErrorMessage; +import org.hisp.dhis.feedback.MergeReport; +import org.hisp.dhis.merge.MergeParams; +import org.hisp.dhis.merge.MergeRequest; +import org.hisp.dhis.merge.MergeService; +import org.hisp.dhis.merge.MergeType; +import org.hisp.dhis.merge.MergeValidator; +import org.springframework.stereotype.Service; + +/** + * Main class for a {@link CategoryOptionCombo} merge. + * + * @author david mackessy + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class CategoryOptionComboMergeService implements MergeService { + + private final CategoryService categoryService; + private final MetadataCategoryOptionComboMergeHandler metadataMergeHandler; + private final DataCategoryOptionComboMergeHandler dataMergeHandler; + private final MergeValidator validator; + private final EntityManager entityManager; + private List metadataMergeHandlers; + private List dataMergeHandlers; + private List auditMergeHandlers; + + @Override + public MergeRequest validate(@Nonnull MergeParams params, @Nonnull MergeReport mergeReport) { + MergeRequest request = + validator.validateUIDs(params, mergeReport, MergeType.CATEGORY_OPTION_COMBO); + + // merge-specific validation + if (params.getDataMergeStrategy() == null) { + mergeReport.addErrorMessage(new ErrorMessage(ErrorCode.E1534)); + } + return request; + } + + @Override + public MergeReport merge(@Nonnull MergeRequest request, @Nonnull MergeReport mergeReport) { + log.info("Performing CategoryOptionCombo merge"); + + List sources = + categoryService.getCategoryOptionCombosByUid(request.getSources()); + CategoryOptionCombo target = + categoryService.getCategoryOptionCombo(request.getTarget().getValue()); + + // merge metadata + log.info("Handling CategoryOptionCombo reference associations and merges"); + metadataMergeHandlers.forEach(h -> h.merge(sources, target)); + dataMergeHandlers.forEach(h -> h.merge(sources, target, request)); + auditMergeHandlers.forEach(h -> h.merge(sources, request)); + + // a flush is required here to bring the system into a consistent state. This is required so + // that the deletion handler hooks, which are usually done using JDBC (non-Hibernate), can + // see the most up-to-date state, including merges done using Hibernate. + entityManager.flush(); + + // handle deletes + if (request.isDeleteSources()) handleDeleteSources(sources, mergeReport); + + return mergeReport; + } + + private void handleDeleteSources(List sources, MergeReport mergeReport) { + log.info("Deleting source CategoryOptionCombos"); + for (CategoryOptionCombo source : sources) { + mergeReport.addDeletedSource(source.getUid()); + categoryService.deleteCategoryOptionCombo(source); + } + } + + @PostConstruct + private void initMergeHandlers() { + metadataMergeHandlers = + List.of( + metadataMergeHandler::handleCategoryOptions, + metadataMergeHandler::handleCategoryCombos, + metadataMergeHandler::handlePredictors, + metadataMergeHandler::handleDataElementOperands, + metadataMergeHandler::handleMinMaxDataElements, + metadataMergeHandler::handleSmsCodes); + + dataMergeHandlers = + List.of( + dataMergeHandler::handleDataValues, + dataMergeHandler::handleDataApprovals, + dataMergeHandler::handleEvents, + dataMergeHandler::handleCompleteDataSetRegistrations); + + auditMergeHandlers = + List.of( + dataMergeHandler::handleDataValueAudits, dataMergeHandler::handleDataApprovalAudits); + } + + /** + * Functional interface representing a {@link CategoryOptionCombo} data merge operation. + * + * @author david mackessy + */ + @FunctionalInterface + public interface MetadataMergeHandler { + void merge(@Nonnull List sources, @Nonnull CategoryOptionCombo target); + } + + @FunctionalInterface + public interface DataMergeHandler { + void merge( + @Nonnull List sources, + @Nonnull CategoryOptionCombo target, + @Nonnull MergeRequest request); + } + + @FunctionalInterface + public interface DataMergeHandlerNoTarget { + void merge(@Nonnull List sources, @Nonnull MergeRequest request); + } +} diff --git a/dhis-2/dhis-services/dhis-service-administration/src/main/java/org/hisp/dhis/merge/category/optioncombo/DataCategoryOptionComboMergeHandler.java b/dhis-2/dhis-services/dhis-service-administration/src/main/java/org/hisp/dhis/merge/category/optioncombo/DataCategoryOptionComboMergeHandler.java new file mode 100644 index 000000000000..81c144e7c59e --- /dev/null +++ b/dhis-2/dhis-services/dhis-service-administration/src/main/java/org/hisp/dhis/merge/category/optioncombo/DataCategoryOptionComboMergeHandler.java @@ -0,0 +1,411 @@ +/* + * Copyright (c) 2004-2024, 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: + * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 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. + * Neither the name of the HISP project 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.merge.category.optioncombo; + +import static org.hisp.dhis.merge.DataMergeStrategy.DISCARD; +import static org.hisp.dhis.merge.DataMergeStrategy.LAST_UPDATED; + +import jakarta.persistence.EntityManager; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.function.BiPredicate; +import java.util.function.Function; +import java.util.stream.Collectors; +import javax.annotation.Nonnull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.hisp.dhis.category.CategoryOptionCombo; +import org.hisp.dhis.common.BaseIdentifiableObject; +import org.hisp.dhis.common.UID; +import org.hisp.dhis.dataapproval.DataApproval; +import org.hisp.dhis.dataapproval.DataApprovalAudit; +import org.hisp.dhis.dataapproval.DataApprovalAuditStore; +import org.hisp.dhis.dataapproval.DataApprovalStore; +import org.hisp.dhis.dataset.CompleteDataSetRegistration; +import org.hisp.dhis.dataset.CompleteDataSetRegistrationStore; +import org.hisp.dhis.datavalue.DataValue; +import org.hisp.dhis.datavalue.DataValueAudit; +import org.hisp.dhis.datavalue.DataValueAuditStore; +import org.hisp.dhis.datavalue.DataValueStore; +import org.hisp.dhis.maintenance.MaintenanceStore; +import org.hisp.dhis.merge.DataMergeStrategy; +import org.hisp.dhis.merge.MergeRequest; +import org.hisp.dhis.program.Event; +import org.hisp.dhis.program.EventStore; +import org.springframework.stereotype.Component; + +/** + * Merge handler for data types. + * + * @author david mackessy + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class DataCategoryOptionComboMergeHandler { + + private final DataValueStore dataValueStore; + private final DataValueAuditStore dataValueAuditStore; + private final DataApprovalAuditStore dataApprovalAuditStore; + private final DataApprovalStore dataApprovalStore; + private final EventStore eventStore; + private final MaintenanceStore maintenanceStore; + private final CompleteDataSetRegistrationStore completeDataSetRegistrationStore; + private final EntityManager entityManager; + + public void handleDataValues( + @Nonnull List sources, + @Nonnull CategoryOptionCombo target, + @Nonnull MergeRequest mergeRequest) { + if (DISCARD == mergeRequest.getDataMergeStrategy()) { + log.info("Deleting source data values as dataMergeStrategy is DISCARD"); + dataValueStore.deleteDataValuesByCategoryOptionCombo(sources); + dataValueStore.deleteDataValuesByAttributeOptionCombo(sources); + } else { + log.info("Merging source data values as dataMergeStrategy is LAST_UPDATED"); + dataValueStore.mergeDataValuesWithCategoryOptionCombos(target, sources); + dataValueStore.mergeDataValuesWithAttributeOptionCombos(target, sources); + } + } + + /** + * All {@link DataValueAudit}s will either be deleted or left as is, based on whether the source + * {@link CategoryOptionCombo}s are being deleted or not. + */ + public void handleDataValueAudits( + @Nonnull List sources, @Nonnull MergeRequest mergeRequest) { + if (mergeRequest.isDeleteSources()) { + log.info( + "Deleting source data value audits as source CategoryOptionCombos are being deleted"); + sources.forEach(dataValueAuditStore::deleteDataValueAudits); + } else { + log.info( + "Leaving source data value audit records as is, source CategoryOptionCombos are not being deleted"); + } + } + + public void handleDataApprovals( + @Nonnull List sources, + @Nonnull CategoryOptionCombo target, + @Nonnull MergeRequest mergeRequest) { + if (DISCARD == mergeRequest.getDataMergeStrategy()) { + log.info("Deleting source data approvals as dataMergeStrategy is DISCARD"); + dataApprovalStore.deleteByCategoryOptionCombo( + UID.of(sources.stream().map(BaseIdentifiableObject::getUid).toList())); + } else { + log.info("Merging source data approvals as dataMergeStrategy is LAST_UPDATED"); + List sourceDas = + dataApprovalStore.getByCategoryOptionCombo( + UID.of(sources.stream().map(BaseIdentifiableObject::getUid).toList())); + + // sort into duplicate & non-duplicates + // get map of target data approvals, using the duplicate key constraints as the key + Map targetDas = + dataApprovalStore.getByCategoryOptionCombo(List.of(UID.of(target.getUid()))).stream() + .collect(Collectors.toMap(getDataApprovalKey, da -> da)); + log.info("{} data approvals retrieved for target categoryOptionCombo", targetDas.size()); + + Map> sourceDuplicateList = + sourceDas.stream() + .collect(Collectors.partitioningBy(dv -> dataApprovalDuplicates.test(dv, targetDas))); + + if (!sourceDuplicateList.get(false).isEmpty()) + handleDaNonDuplicates(sourceDuplicateList.get(false), target); + if (!sourceDuplicateList.get(true).isEmpty()) + handleDaDuplicates(sourceDuplicateList.get(true), targetDas, target, sources); + } + } + + /** + * Deletes {@link DataApprovalAudit}s if the source {@link CategoryOptionCombo}s are being + * deleted. Otherwise, no other action taken. + */ + public void handleDataApprovalAudits( + @Nonnull List sources, @Nonnull MergeRequest mergeRequest) { + if (mergeRequest.isDeleteSources()) { + log.info( + "Deleting source data approval audits as source CategoryOptionCombos are being deleted"); + sources.forEach(dataApprovalAuditStore::deleteDataApprovalAudits); + } else { + log.info( + "Leaving source data approval audit records as is, source CategoryOptionCombos are not being deleted"); + } + } + + /** + * Deletes {@link Event}s if the {@link DataMergeStrategy}s is {@link DataMergeStrategy#DISCARD}. + * Otherwise, reassigns source {@link Event}s attributeOptionCombos to the target {@link + * CategoryOptionCombo} if the {@link DataMergeStrategy}s is {@link + * DataMergeStrategy#LAST_UPDATED}. + */ + public void handleEvents( + @Nonnull List sources, + @Nonnull CategoryOptionCombo target, + @Nonnull MergeRequest mergeRequest) { + if (DISCARD == mergeRequest.getDataMergeStrategy()) { + log.info("Deleting source events as dataMergeStrategy is DISCARD"); + String aocIds = + sources.stream().map(s -> String.valueOf(s.getId())).collect(Collectors.joining(",")); + + List eventsToDelete = + maintenanceStore.getDeletionEntities( + "(select distinct uid from event where attributeoptioncomboid in (%s))" + .formatted(aocIds)); + + String eventSelect = + "(select distinct eventid from event where attributeoptioncomboid in (%s))" + .formatted(aocIds); + + maintenanceStore.hardDeleteEvents( + eventsToDelete, + eventSelect, + "delete from event where attributeoptioncomboid in (%s)".formatted(aocIds)); + } else { + log.info("Merging source events as dataMergeStrategy is LAST_UPDATED"); + + eventStore.setAttributeOptionCombo( + sources.stream().map(BaseIdentifiableObject::getId).collect(Collectors.toSet()), + target.getId()); + } + } + + /** + * Deletes {@link CompleteDataSetRegistration}s if the {@link DataMergeStrategy}s is {@link + * DataMergeStrategy#DISCARD}. Otherwise, if the {@link DataMergeStrategy}s is {@link + * DataMergeStrategy#LAST_UPDATED}, it groups source {@link CompleteDataSetRegistration}s into + * duplicates and non-duplicates for further processing. + */ + public void handleCompleteDataSetRegistrations( + @Nonnull List sources, + @Nonnull CategoryOptionCombo target, + @Nonnull MergeRequest mergeRequest) { + if (DISCARD == mergeRequest.getDataMergeStrategy()) { + log.info("Deleting source complete data set registrations as dataMergeStrategy is DISCARD"); + completeDataSetRegistrationStore.deleteByCategoryOptionCombo(sources); + } else if (LAST_UPDATED == mergeRequest.getDataMergeStrategy()) { + log.info( + "Merging source complete data set registrations as dataMergeStrategy is LAST_UPDATED"); + // get CDSRs from sources + List sourceCdsr = + completeDataSetRegistrationStore.getAllByCategoryOptionCombo( + UID.of(sources.stream().map(BaseIdentifiableObject::getUid).toList())); + + // get map of target cdsr, using the duplicate key constraints as the key + Map targetcdsr = + completeDataSetRegistrationStore + .getAllByCategoryOptionCombo(UID.of(List.of(target.getUid()))) + .stream() + .collect(Collectors.toMap(getCdsrKey, cdsr -> cdsr)); + + Map> sourceDuplicateList = + sourceCdsr.stream() + .collect(Collectors.partitioningBy(cdsr -> cdsrDuplicates.test(cdsr, targetcdsr))); + + if (!sourceDuplicateList.get(false).isEmpty()) { + handleCdsrNonDuplicates(sourceDuplicateList.get(false), target); + } + if (!sourceDuplicateList.get(true).isEmpty()) { + handleCdsrDuplicates(sourceDuplicateList.get(true), targetcdsr, target, sources); + } + } + } + + private void handleCdsrDuplicates( + @Nonnull List sourceCdsrDuplicates, + @Nonnull Map targetCdsr, + @Nonnull CategoryOptionCombo target, + @Nonnull List sources) { + log.info("Merging source complete data set registration duplicates"); + // group CompleteDataSetRegistration by key, so we can deal with each duplicate correctly + Map> sourceCdsrGroupedByKey = + sourceCdsrDuplicates.stream().collect(Collectors.groupingBy(getCdsrKey)); + + // filter groups down to single CDSR with latest date + List filtered = + sourceCdsrGroupedByKey.values().stream() + .map( + ls -> + Collections.max( + ls, Comparator.comparing(CompleteDataSetRegistration::getLastUpdated))) + .toList(); + + for (CompleteDataSetRegistration source : filtered) { + CompleteDataSetRegistration matchingTargetCdsr = targetCdsr.get(getCdsrKey.apply(source)); + + if (matchingTargetCdsr.getLastUpdated().before(source.getLastUpdated())) { + completeDataSetRegistrationStore.deleteCompleteDataSetRegistration(matchingTargetCdsr); + + CompleteDataSetRegistration copyWithNewRef = + CompleteDataSetRegistration.copyWithNewAttributeOptionCombo(source, target); + completeDataSetRegistrationStore.saveWithoutUpdatingLastUpdated(copyWithNewRef); + } + } + + // delete the rest of the source CDSRs after handling the last update duplicate + completeDataSetRegistrationStore.deleteByCategoryOptionCombo(sources); + } + + /** + * Handles non duplicate CompleteDataSetRegistrations. As CompleteDataSetRegistration has a + * composite primary key which includes CategoryOptionCombo, this cannot be updated. A new copy of + * the CompleteDataSetRegistration is required, which uses the target CompleteDataSetRegistration + * as the new ref. + * + * @param sourceCdsr sources to handle + * @param target target to use as new ref in copy + */ + private void handleCdsrNonDuplicates( + @Nonnull List sourceCdsr, @Nonnull CategoryOptionCombo target) { + log.info("Merging source complete data set registration non-duplicates"); + sourceCdsr.forEach( + cdsr -> { + CompleteDataSetRegistration copyWithNewAoc = + CompleteDataSetRegistration.copyWithNewAttributeOptionCombo(cdsr, target); + completeDataSetRegistrationStore.saveWithoutUpdatingLastUpdated(copyWithNewAoc); + }); + + sourceCdsr.forEach(completeDataSetRegistrationStore::deleteCompleteDataSetRegistration); + } + + /** + * Method to handle merging duplicate {@link DataApproval}s. There may be multiple potential + * {@link DataApproval} duplicates. The {@link DataApproval} with the latest `lastUpdated` value + * is filtered out, the rest are then deleted at the end of the process (We can only have one of + * these entries due to the unique key constraint). The filtered-out {@link DataApproval} will be + * compared with the target {@link DataApproval} lastUpdated date. + * + *

If the target date is later, no action is required. + * + *

If the source date is later, the source {@link DataApproval} has its {@link + * CategoryOptionCombo} set as the target. The matching target {@link DataApproval}s will then be + * deleted. + * + * @param sourceDaDuplicates {@link DataApproval}s to merge + * @param targetDaMap target map of {@link DataApproval}s to check duplicates against + * @param target target {@link CategoryOptionCombo} + */ + private void handleDaDuplicates( + @Nonnull Collection sourceDaDuplicates, + @Nonnull Map targetDaMap, + @Nonnull CategoryOptionCombo target, + @Nonnull List sources) { + log.info( + "Handling " + + sourceDaDuplicates.size() + + " duplicate data approvals, keeping later lastUpdated value"); + + // group Data approvals by key, so we can deal with each duplicate correctly + Map> sourceDataApprovalsGroupedByKey = + sourceDaDuplicates.stream().collect(Collectors.groupingBy(getDataApprovalKey)); + + // filter groups down to single DA with latest date + List filtered = + sourceDataApprovalsGroupedByKey.values().stream() + .map(ls -> Collections.max(ls, Comparator.comparing(DataApproval::getLastUpdated))) + .toList(); + + for (DataApproval source : filtered) { + DataApproval matchingTargetDataApproval = targetDaMap.get(getDataApprovalKey.apply(source)); + + if (matchingTargetDataApproval.getLastUpdated().before(source.getLastUpdated())) { + dataApprovalStore.deleteDataApproval(matchingTargetDataApproval); + // flush is required here as it's not possible to update a source DataApproval with + // essentially the same unique constraint key as the target DataApproval until it is removed + // from the Hibernate session + entityManager.flush(); + + source.setAttributeOptionCombo(target); + } + } + + // delete the rest of the source data values after handling the last update duplicate + dataApprovalStore.deleteByCategoryOptionCombo( + UID.of(sources.stream().map(BaseIdentifiableObject::getUid).toList())); + } + + /** + * Method to handle merging non-duplicate {@link DataApproval}s. Source {@link DataApproval}s will + * be assigned the target {@link CategoryOptionCombo} ref. + * + * @param dataApprovals {@link DataApproval}s to merge + * @param target target {@link CategoryOptionCombo} + */ + private void handleDaNonDuplicates( + @Nonnull List dataApprovals, @Nonnull CategoryOptionCombo target) { + log.info( + "Handling " + + dataApprovals.size() + + " non duplicate data approvals. Each will have their attribute option combo set as the target"); + + dataApprovals.forEach(sourceDataApproval -> sourceDataApproval.setAttributeOptionCombo(target)); + } + + private static final Function getCocDataValueKey = + dv -> + String.valueOf(dv.getPeriod().getId()) + + dv.getSource().getId() + + dv.getDataElement().getId() + + dv.getAttributeOptionCombo().getId(); + + private static final Function getDataApprovalKey = + da -> + String.valueOf(da.getPeriod().getId()) + + da.getDataApprovalLevel().getId() + + da.getWorkflow().getId() + + da.getOrganisationUnit().getId(); + + private static final BiPredicate> cocDataValueDuplicates = + (sourceDv, targetDvs) -> targetDvs.containsKey(getCocDataValueKey.apply(sourceDv)); + + private static final BiPredicate> dataApprovalDuplicates = + (sourceDa, targetDas) -> targetDas.containsKey(getDataApprovalKey.apply(sourceDa)); + + private static final Function getAocDataValueKey = + dv -> + String.valueOf(dv.getPeriod().getId()) + + dv.getSource().getId() + + dv.getDataElement().getId() + + dv.getCategoryOptionCombo().getId(); + + private static final BiPredicate> aocDataValueDuplicates = + (sourceDv, targetDvs) -> targetDvs.containsKey(getAocDataValueKey.apply(sourceDv)); + + private static final Function getCdsrKey = + cdsr -> + String.valueOf(cdsr.getPeriod().getId()) + + cdsr.getSource().getId() + + cdsr.getDataSet().getId(); + + private static final BiPredicate< + CompleteDataSetRegistration, Map> + cdsrDuplicates = + (sourceCdsr, targetCdsr) -> targetCdsr.containsKey(getCdsrKey.apply(sourceCdsr)); +} diff --git a/dhis-2/dhis-services/dhis-service-administration/src/main/java/org/hisp/dhis/merge/category/optioncombo/MetadataCategoryOptionComboMergeHandler.java b/dhis-2/dhis-services/dhis-service-administration/src/main/java/org/hisp/dhis/merge/category/optioncombo/MetadataCategoryOptionComboMergeHandler.java new file mode 100644 index 000000000000..5164eed06553 --- /dev/null +++ b/dhis-2/dhis-services/dhis-service-administration/src/main/java/org/hisp/dhis/merge/category/optioncombo/MetadataCategoryOptionComboMergeHandler.java @@ -0,0 +1,180 @@ +/* + * Copyright (c) 2004-2024, 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: + * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 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. + * Neither the name of the HISP project 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.merge.category.optioncombo; + +import java.util.List; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.hisp.dhis.category.CategoryCombo; +import org.hisp.dhis.category.CategoryComboStore; +import org.hisp.dhis.category.CategoryOption; +import org.hisp.dhis.category.CategoryOptionCombo; +import org.hisp.dhis.category.CategoryOptionStore; +import org.hisp.dhis.common.BaseIdentifiableObject; +import org.hisp.dhis.common.UID; +import org.hisp.dhis.datadimensionitem.DataDimensionItemStore; +import org.hisp.dhis.dataelement.DataElementOperand; +import org.hisp.dhis.dataelement.DataElementOperandStore; +import org.hisp.dhis.minmax.MinMaxDataElement; +import org.hisp.dhis.minmax.MinMaxDataElementStore; +import org.hisp.dhis.predictor.Predictor; +import org.hisp.dhis.predictor.PredictorStore; +import org.hisp.dhis.sms.command.code.SMSCode; +import org.hisp.dhis.sms.command.hibernate.SMSCommandStore; +import org.springframework.stereotype.Component; + +/** + * Merge handler for metadata entities. + * + * @author david mackessy + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class MetadataCategoryOptionComboMergeHandler { + + private final CategoryOptionStore categoryOptionStore; + private final CategoryComboStore categoryComboStore; + private final DataElementOperandStore dataElementOperandStore; + private final DataDimensionItemStore dataDimensionItemStore; + private final MinMaxDataElementStore minMaxDataElementStore; + private final PredictorStore predictorStore; + private final SMSCommandStore smsCommandStore; + + /** + * Remove sources from {@link CategoryOption} and add target to {@link CategoryOption} + * + * @param sources to be removed + * @param target to add + */ + public void handleCategoryOptions(List sources, CategoryOptionCombo target) { + log.info("Merging source category options"); + List categoryOptions = + categoryOptionStore.getByCategoryOptionCombo( + UID.of(sources.stream().map(BaseIdentifiableObject::getUid).toList())); + + categoryOptions.forEach( + co -> { + co.addCategoryOptionCombo(target); + co.removeCategoryOptionCombos(sources); + }); + } + + /** + * Remove sources from {@link CategoryCombo} and add target to {@link CategoryCombo} + * + * @param sources to be removed + * @param target to add + */ + public void handleCategoryCombos(List sources, CategoryOptionCombo target) { + log.info("Merging source category combos"); + List categoryCombos = + categoryComboStore.getByCategoryOptionCombo( + UID.of(sources.stream().map(BaseIdentifiableObject::getUid).toList())); + + categoryCombos.forEach( + cc -> { + cc.addCategoryOptionCombo(target); + cc.removeCategoryOptionCombos(sources); + }); + } + + /** + * Set target to {@link DataElementOperand} + * + * @param sources to be actioned + * @param target to add + */ + public void handleDataElementOperands( + List sources, CategoryOptionCombo target) { + log.info("Merging source data element operands"); + List dataElementOperands = + dataElementOperandStore.getByCategoryOptionCombo( + UID.of(sources.stream().map(BaseIdentifiableObject::getUid).toList())); + + dataElementOperands.forEach(deo -> deo.setCategoryOptionCombo(target)); + + // A data element operand is also a data dimension item. + // The above update does not cascade the reference change though. + // The Data dimension item table also needs updating + int dataDimensionItemsUpdated = + dataDimensionItemStore.updateDeoCategoryOptionCombo( + sources.stream().map(BaseIdentifiableObject::getId).collect(Collectors.toSet()), + target.getId()); + log.info( + "{} data dimension items updated as part of category option combo merge", + dataDimensionItemsUpdated); + } + + /** + * Set target to {@link MinMaxDataElement} + * + * @param sources to be actioned + * @param target to add + */ + public void handleMinMaxDataElements( + List sources, CategoryOptionCombo target) { + log.info("Merging source min max data elements"); + List minMaxDataElements = + minMaxDataElementStore.getByCategoryOptionCombo( + UID.of(sources.stream().map(BaseIdentifiableObject::getUid).toList())); + + minMaxDataElements.forEach(mmde -> mmde.setOptionCombo(target)); + } + + /** + * Set target to {@link Predictor} + * + * @param sources to be actioned + * @param target to add + */ + public void handlePredictors(List sources, CategoryOptionCombo target) { + log.info("Merging source predictors"); + List predictors = + predictorStore.getByCategoryOptionCombo( + UID.of(sources.stream().map(BaseIdentifiableObject::getUid).toList())); + + predictors.forEach(p -> p.setOutputCombo(target)); + } + + /** + * Set target to {@link SMSCode} + * + * @param sources to be removed + * @param target to add + */ + public void handleSmsCodes(List sources, CategoryOptionCombo target) { + log.info("Merging source SMS codes"); + List smsCodes = + smsCommandStore.getCodesByCategoryOptionCombo( + UID.of(sources.stream().map(BaseIdentifiableObject::getUid).toList())); + + smsCodes.forEach(smsCode -> smsCode.setOptionId(target)); + } +} diff --git a/dhis-2/dhis-services/dhis-service-administration/src/main/java/org/hisp/dhis/merge/dataelement/handler/DataDataElementMergeHandler.java b/dhis-2/dhis-services/dhis-service-administration/src/main/java/org/hisp/dhis/merge/dataelement/handler/DataDataElementMergeHandler.java index 28f8c1fc2d92..97cae9fa5c7a 100644 --- a/dhis-2/dhis-services/dhis-service-administration/src/main/java/org/hisp/dhis/merge/dataelement/handler/DataDataElementMergeHandler.java +++ b/dhis-2/dhis-services/dhis-service-administration/src/main/java/org/hisp/dhis/merge/dataelement/handler/DataDataElementMergeHandler.java @@ -27,13 +27,12 @@ */ package org.hisp.dhis.merge.dataelement.handler; -import jakarta.persistence.EntityManager; -import java.util.Collection; -import java.util.Collections; -import java.util.Comparator; +import static org.hisp.dhis.datavalue.DataValueUtil.dataValueWithNewDataElement; + import java.util.List; import java.util.Map; import java.util.function.BiPredicate; +import java.util.function.Function; import java.util.stream.Collectors; import javax.annotation.Nonnull; import lombok.RequiredArgsConstructor; @@ -43,6 +42,8 @@ import org.hisp.dhis.datavalue.DataValueAudit; import org.hisp.dhis.datavalue.DataValueAuditStore; import org.hisp.dhis.datavalue.DataValueStore; +import org.hisp.dhis.merge.CommonDataMergeHandler; +import org.hisp.dhis.merge.CommonDataMergeHandler.DataValueMergeParams; import org.hisp.dhis.merge.DataMergeStrategy; import org.hisp.dhis.merge.MergeRequest; import org.springframework.stereotype.Component; @@ -59,7 +60,7 @@ public class DataDataElementMergeHandler { private final DataValueStore dataValueStore; private final DataValueAuditStore dataValueAuditStore; - private final EntityManager entityManager; + private final CommonDataMergeHandler commonDataMergeHandler; /** * Method retrieving {@link DataValue}s by source {@link DataElement} references. All retrieved @@ -74,32 +75,33 @@ public void handleDataValueDataElement( @Nonnull List sources, @Nonnull DataElement target, @Nonnull MergeRequest mergeRequest) { - // get DVs from sources - List sourceDataValues = dataValueStore.getAllDataValuesByDataElement(sources); - log.info(sourceDataValues.size() + " source data values retrieved"); - - // get map of target data values, using the duplicate key constraints as the key - Map targetDataValues = - dataValueStore.getAllDataValuesByDataElement(List.of(target)).stream() - .collect(Collectors.toMap(DataDataElementMergeHandler::getDataValueKey, dv -> dv)); - log.info(targetDataValues.size() + " target data values retrieved"); - - // merge based on chosen strategy - DataMergeStrategy dataMergeStrategy = mergeRequest.getDataMergeStrategy(); - if (dataMergeStrategy == DataMergeStrategy.DISCARD) { - log.info(dataMergeStrategy + " dataMergeStrategy being used, deleting source data values"); - sources.forEach(dataValueStore::deleteDataValues); - } else if (dataMergeStrategy == DataMergeStrategy.LAST_UPDATED) { - log.info(dataMergeStrategy + " dataMergeStrategy being used"); - Map> sourceDuplicateList = - sourceDataValues.stream() - .collect( - Collectors.partitioningBy(dv -> dataValueDuplicates.test(dv, targetDataValues))); - - if (!sourceDuplicateList.get(false).isEmpty()) - handleNonDuplicates(sourceDuplicateList.get(false), sources, target); - if (!sourceDuplicateList.get(true).isEmpty()) - handleDuplicates(sourceDuplicateList.get(true), targetDataValues, sources, target); + if (DataMergeStrategy.DISCARD == mergeRequest.getDataMergeStrategy()) { + log.info( + mergeRequest.getDataMergeStrategy() + + " dataMergeStrategy being used, deleting source data values"); + dataValueStore.deleteDataValues(sources); + } else { + // get DVs from sources + List sourceDataValues = dataValueStore.getAllDataValuesByDataElement(sources); + log.info("{} data values retrieved for source DataElements", sourceDataValues.size()); + + // get map of target data values, using the duplicate key constraints as the key + Map targetDataValues = + dataValueStore.getAllDataValuesByDataElement(List.of(target)).stream() + .collect(Collectors.toMap(getDataValueKey, dv -> dv)); + log.info("{} data values retrieved for target DataElement", targetDataValues.size()); + + commonDataMergeHandler.handleDataValues( + new DataValueMergeParams<>( + mergeRequest, + sources, + target, + sourceDataValues, + targetDataValues, + dataValueStore::deleteDataValues, + dataValueDuplicates, + dataValueWithNewDataElement, + getDataValueKey)); } } @@ -121,108 +123,13 @@ public void handleDataValueAuditDataElement( } } - /** - * Method to handle merging duplicate {@link DataValue}s. There may be multiple potential {@link - * DataValue} duplicates. The {@link DataValue} with the latest `lastUpdated` value is filtered - * out, the rest are then deleted at the end of the process (We can only have one of these entries - * due to the composite key constraint). The filtered-out {@link DataValue} will be compared with - * the target {@link DataValue} lastUpdated date. - * - *

If the target date is later, no action is required. - * - *

If the source date is later, then A new {@link DataValue} will be created from the old - * {@link DataValue} values and it will use the target {@link DataElement} ref. This new {@link - * DataValue} will be saved to the database. This sequence is required as a {@link DataValue} has - * a composite primary key which includes {@link DataElement}. This prohibits updating the {@link - * DataElement} ref in a source {@link DataValue}. The matching target {@link DataValue}s will - * then be deleted. - * - * @param sourceDataValuesDuplicates {@link DataValue}s to merge - * @param targetDataValueMap target {@link DataValue}s - * @param sources source {@link DataElement}s - * @param target target {@link DataElement} - */ - private void handleDuplicates( - @Nonnull Collection sourceDataValuesDuplicates, - @Nonnull Map targetDataValueMap, - @Nonnull List sources, - @Nonnull DataElement target) { - log.info( - "Handling " - + sourceDataValuesDuplicates.size() - + " duplicate data values, keeping later lastUpdated value"); - - // group Data values by key so we can deal with each duplicate correctly - Map> sourceDataValuesGroupedByKey = - sourceDataValuesDuplicates.stream() - .collect(Collectors.groupingBy(DataDataElementMergeHandler::getDataValueKey)); - - // filter groups down to single DV with latest date - List filtered = - sourceDataValuesGroupedByKey.values().stream() - .map(ls -> Collections.max(ls, Comparator.comparing(DataValue::getLastUpdated))) - .toList(); - - for (DataValue source : filtered) { - DataValue matchingTargetDataValue = targetDataValueMap.get(getDataValueKey(source)); - - if (matchingTargetDataValue.getLastUpdated().before(source.getLastUpdated())) { - dataValueStore.deleteDataValue(matchingTargetDataValue); - - // detaching is required here as it's not possible to add a new DataValue with essentially - // the same composite primary key - Throws `NonUniqueObjectException: A different object - // with the same identifier value was already associated with the session` - entityManager.detach(matchingTargetDataValue); - DataValue copyWithNewDataElementRef = DataValue.dataValueWithNewDataElement(source, target); - dataValueStore.addDataValue(copyWithNewDataElementRef); - } - } - - // delete the rest of the source data values after handling the last update duplicate - sources.forEach(dataValueStore::deleteDataValues); - } - - /** - * Method to handle merging non-duplicate {@link DataValue}s. A new {@link DataValue} will be - * created from the old {@link DataValue} values, and it will use the target {@link DataElement} - * ref. This new {@link DataValue} will be saved to the database. This sequence is required as a - * {@link DataValue} has a composite primary key which includes {@link DataElement}. This - * prohibits updating the {@link DataElement} ref in a source {@link DataValue}. - * - *

All source {@link DataValue}s will then be deleted. - * - * @param dataValues {@link DataValue}s to merge - * @param sources source {@link DataElement}s - * @param target target {@link DataElement} - */ - private void handleNonDuplicates( - @Nonnull List dataValues, - @Nonnull List sources, - @Nonnull DataElement target) { - log.info( - "Handling " - + dataValues.size() - + " non duplicate data values. Add new DataValue entry (using target DataElement ref) and delete old source entry"); - - dataValues.forEach( - sourceDataValue -> { - DataValue copyWithNewDataElementRef = - DataValue.dataValueWithNewDataElement(sourceDataValue, target); - - dataValueStore.addDataValue(copyWithNewDataElementRef); - }); - - log.info("Deleting all data values referencing source data elements"); - sources.forEach(dataValueStore::deleteDataValues); - } + private static final Function getDataValueKey = + dv -> + String.valueOf(dv.getPeriod().getId()) + + dv.getSource().getId() + + dv.getCategoryOptionCombo().getId() + + dv.getAttributeOptionCombo().getId(); public static final BiPredicate> dataValueDuplicates = - (sourceDv, targetDvs) -> targetDvs.containsKey(getDataValueKey(sourceDv)); - - private static String getDataValueKey(DataValue dv) { - return String.valueOf(dv.getPeriod().getId()) - + dv.getSource().getId() - + dv.getCategoryOptionCombo().getId() - + dv.getAttributeOptionCombo().getId(); - } + (sourceDv, targetDvs) -> targetDvs.containsKey(getDataValueKey.apply(sourceDv)); } diff --git a/dhis-2/dhis-services/dhis-service-administration/src/main/resources/data-integrity-checks.yaml b/dhis-2/dhis-services/dhis-service-administration/src/main/resources/data-integrity-checks.yaml index 209893b659a8..53d9e07d9c4a 100644 --- a/dhis-2/dhis-services/dhis-service-administration/src/main/resources/data-integrity-checks.yaml +++ b/dhis-2/dhis-services/dhis-service-administration/src/main/resources/data-integrity-checks.yaml @@ -84,4 +84,5 @@ checks: - users/user_roles_no_authorities.yaml - users/user_roles_with_no_users.yaml - users/users_with_invalid_usernames.yaml + - users/users_with_all_authority.yaml - file_resources/file_resources_no_icon.yaml diff --git a/dhis-2/dhis-services/dhis-service-administration/src/main/resources/data-integrity-checks/users/users_with_all_authority.yaml b/dhis-2/dhis-services/dhis-service-administration/src/main/resources/data-integrity-checks/users/users_with_all_authority.yaml new file mode 100644 index 000000000000..c67587c49ae1 --- /dev/null +++ b/dhis-2/dhis-services/dhis-service-administration/src/main/resources/data-integrity-checks/users/users_with_all_authority.yaml @@ -0,0 +1,62 @@ +# Copyright (c) 2004-2025, University of Oslo +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# 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. +# Neither the name of the HISP project 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. +# +--- +name: users_with_all_authority +description: Users who have ALL authority assigned. +section: Users +section_order: 6 +summary_sql: >- + SELECT COUNT(*) as value, + 100.0 * COUNT(*) / NULLIF((SELECT COUNT(*) FROM userinfo), 0) as percent + from userinfo where userinfoid IN ( + SELECT userid from userrolemembers where userroleid IN ( + SELECT DISTINCT userroleid from userroleauthorities + where authority = 'ALL' + ) + ); +details_sql: > + SELECT c.uid, + c.username as name, + CASE WHEN c.disabled THEN 'Disabled' ELSE 'Active' END as comment, + array_agg(b.role_name) as refs + from userrolemembers a + INNER JOIN ( + SELECT userroleid, name as role_name from userrole where userroleid in ( + SELECT DISTINCT userroleid from userroleauthorities + where authority = 'ALL' + ) + ) b on a.userroleid = b.userroleid + INNER JOIN userinfo c on a.userid = c.userinfoid + GROUP BY c.uid,c.username,c.disabled +details_id_type: users +severity: INFO +introduction: > + Users with the ALL authority have unrestricted access to all data and functionality in the system. The total + number of users with this authority should be kept to a minimum. +recommendation: > + Using the list of users provided by the details query, consider whether these users should have the ALL authority + assigned. If they should not have the ALL authority, consider removing it from their user roles. \ No newline at end of file diff --git a/dhis-2/dhis-services/dhis-service-administration/src/test/java/org/hisp/dhis/dataintegrity/DataIntegrityYamlReaderTest.java b/dhis-2/dhis-services/dhis-service-administration/src/test/java/org/hisp/dhis/dataintegrity/DataIntegrityYamlReaderTest.java index 77fb67fdd3ba..071db0a83e21 100644 --- a/dhis-2/dhis-services/dhis-service-administration/src/test/java/org/hisp/dhis/dataintegrity/DataIntegrityYamlReaderTest.java +++ b/dhis-2/dhis-services/dhis-service-administration/src/test/java/org/hisp/dhis/dataintegrity/DataIntegrityYamlReaderTest.java @@ -57,7 +57,7 @@ void testReadDataIntegrityYaml() { List checks = new ArrayList<>(); readYaml(checks, "data-integrity-checks.yaml", "data-integrity-checks", CLASS_PATH); - assertEquals(86, checks.size()); + assertEquals(87, checks.size()); // Names should be unique List allNames = checks.stream().map(DataIntegrityCheck::getName).toList(); diff --git a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/table/JdbcAnalyticsTableManager.java b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/table/JdbcAnalyticsTableManager.java index 31d587e51392..83fc1477018b 100644 --- a/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/table/JdbcAnalyticsTableManager.java +++ b/dhis-2/dhis-services/dhis-service-analytics/src/main/java/org/hisp/dhis/analytics/table/JdbcAnalyticsTableManager.java @@ -654,7 +654,7 @@ private List getDataYears(AnalyticsTableUpdateParams params) { new StringBuilder( replaceQualify( """ - select distinct(extract(year from pes.startdate)) \ + select distinct(year) \ from ${datavalue} dv \ inner join analytics_rs_periodstructure pes on dv.periodid=pes.periodid \ where pes.startdate is not null \ diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/category/DefaultCategoryService.java b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/category/DefaultCategoryService.java index 5de0e136c343..b6f216c85205 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/category/DefaultCategoryService.java +++ b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/category/DefaultCategoryService.java @@ -662,11 +662,16 @@ public void updateCategoryOptionComboNames() { @Override public List getCategoryOptionCombosByCategoryOption( - Collection categoryOptionsUids) { + @Nonnull Collection categoryOptionsUids) { return categoryOptionComboStore.getCategoryOptionCombosByCategoryOption( UID.toValueList(categoryOptionsUids)); } + @Override + public List getCategoryOptionCombosByUid(@Nonnull Collection uids) { + return categoryOptionComboStore.getByUid(UID.toValueList(uids)); + } + // ------------------------------------------------------------------------- // DataElementOperand // ------------------------------------------------------------------------- diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/category/hibernate/HibernateCategoryComboStore.java b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/category/hibernate/HibernateCategoryComboStore.java index 4041df7038e5..65f215f77fb7 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/category/hibernate/HibernateCategoryComboStore.java +++ b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/category/hibernate/HibernateCategoryComboStore.java @@ -29,10 +29,13 @@ import jakarta.persistence.EntityManager; import jakarta.persistence.criteria.CriteriaBuilder; +import java.util.Collection; import java.util.List; +import javax.annotation.Nonnull; import org.hisp.dhis.category.CategoryCombo; import org.hisp.dhis.category.CategoryComboStore; import org.hisp.dhis.common.DataDimensionType; +import org.hisp.dhis.common.UID; import org.hisp.dhis.common.hibernate.HibernateIdentifiableObjectStore; import org.hisp.dhis.security.acl.AclService; import org.springframework.context.ApplicationEventPublisher; @@ -63,4 +66,17 @@ public List getCategoryCombosByDimensionType(DataDimensionType da .addPredicate(root -> builder.equal(root.get("dataDimensionType"), dataDimensionType)) .addPredicate(root -> builder.equal(root.get("name"), "default"))); } + + @Override + public List getByCategoryOptionCombo(@Nonnull Collection uids) { + if (uids.isEmpty()) return List.of(); + return getQuery( + """ + select distinct cc from CategoryCombo cc + join cc.optionCombos coc + where coc.uid in :uids + """) + .setParameter("uids", UID.toValueList(uids)) + .getResultList(); + } } diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/category/hibernate/HibernateCategoryOptionStore.java b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/category/hibernate/HibernateCategoryOptionStore.java index 63937a9ced9a..6528ad1e6c50 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/category/hibernate/HibernateCategoryOptionStore.java +++ b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/category/hibernate/HibernateCategoryOptionStore.java @@ -29,7 +29,9 @@ import jakarta.persistence.EntityManager; import jakarta.persistence.criteria.CriteriaBuilder; +import java.util.Collection; import java.util.List; +import javax.annotation.Nonnull; import org.hisp.dhis.category.Category; import org.hisp.dhis.category.CategoryOption; import org.hisp.dhis.category.CategoryOptionStore; @@ -93,4 +95,17 @@ public List getDataWriteCategoryOptions( .addPredicate( root -> builder.equal(root.join("categories").get("id"), category.getId()))); } + + @Override + public List getByCategoryOptionCombo(@Nonnull Collection uids) { + if (uids.isEmpty()) return List.of(); + return getQuery( + """ + select distinct co from CategoryOption co + join co.categoryOptionCombos coc + where coc.uid in :uids + """) + .setParameter("uids", UID.toValueList(uids)) + .getResultList(); + } } diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/dataapproval/hibernate/HibernateDataApprovalAuditStore.java b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/dataapproval/hibernate/HibernateDataApprovalAuditStore.java index 03858c8aaed7..59ae6d11efb6 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/dataapproval/hibernate/HibernateDataApprovalAuditStore.java +++ b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/dataapproval/hibernate/HibernateDataApprovalAuditStore.java @@ -36,6 +36,7 @@ import java.util.List; import java.util.Set; import org.apache.commons.collections4.CollectionUtils; +import org.hisp.dhis.category.CategoryOptionCombo; import org.hisp.dhis.commons.util.SqlHelper; import org.hisp.dhis.commons.util.TextUtils; import org.hisp.dhis.dataapproval.DataApprovalAudit; @@ -85,6 +86,12 @@ public void deleteDataApprovalAudits(OrganisationUnit organisationUnit) { entityManager.createQuery(hql).setParameter("unit", organisationUnit).executeUpdate(); } + @Override + public void deleteDataApprovalAudits(CategoryOptionCombo coc) { + String hql = "delete from DataApprovalAudit d where d.attributeOptionCombo = :coc"; + entityManager.createQuery(hql).setParameter("coc", coc).executeUpdate(); + } + @Override public List getDataApprovalAudits(DataApprovalAuditQueryParams params) { SqlHelper hlp = new SqlHelper(); diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/dataapproval/hibernate/HibernateDataApprovalStore.java b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/dataapproval/hibernate/HibernateDataApprovalStore.java index bd62c36314ca..0d0e29525ebb 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/dataapproval/hibernate/HibernateDataApprovalStore.java +++ b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/dataapproval/hibernate/HibernateDataApprovalStore.java @@ -44,6 +44,7 @@ import java.util.Map; import java.util.Set; import java.util.stream.Collectors; +import javax.annotation.Nonnull; import lombok.extern.slf4j.Slf4j; import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.StringUtils; @@ -54,6 +55,7 @@ import org.hisp.dhis.category.CategoryService; import org.hisp.dhis.common.BaseIdentifiableObject; import org.hisp.dhis.common.IdentifiableObjectUtils; +import org.hisp.dhis.common.UID; import org.hisp.dhis.dataapproval.DataApproval; import org.hisp.dhis.dataapproval.DataApprovalLevel; import org.hisp.dhis.dataapproval.DataApprovalState; @@ -797,6 +799,33 @@ public List getDataApprovalStatuses( return statusList; } + @Override + public List getByCategoryOptionCombo(@Nonnull Collection uids) { + if (uids.isEmpty()) return List.of(); + return getQuery( + """ + select da from DataApproval da + join da.attributeOptionCombo coc + where coc.uid in :uids + """) + .setParameter("uids", UID.toValueList(uids)) + .getResultList(); + } + + @Override + public void deleteByCategoryOptionCombo(@Nonnull Collection uids) { + if (uids.isEmpty()) return; + String hql = + """ + delete from DataApproval da + where da.attributeOptionCombo in + (select coc from CategoryOptionCombo coc + where coc.uid in :uids) + """; + + entityManager.createQuery(hql).setParameter("uids", UID.toValueList(uids)).executeUpdate(); + } + /** * Get the id for the workflow period that spans the given end date. The workflow period may or * may not be the same as the period for which we are checking data validity. The workflow period diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/datadimensionitem/hibernate/HibernateDataDimensionItemStore.java b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/datadimensionitem/hibernate/HibernateDataDimensionItemStore.java index a190c0a0a037..9f55a7909385 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/datadimensionitem/hibernate/HibernateDataDimensionItemStore.java +++ b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/datadimensionitem/hibernate/HibernateDataDimensionItemStore.java @@ -28,7 +28,10 @@ package org.hisp.dhis.datadimensionitem.hibernate; import jakarta.persistence.EntityManager; +import java.util.Collection; import java.util.List; +import java.util.stream.Collectors; +import javax.annotation.Nonnull; import org.hisp.dhis.common.DataDimensionItem; import org.hisp.dhis.dataelement.DataElement; import org.hisp.dhis.hibernate.HibernateGenericStore; @@ -69,4 +72,19 @@ public List getDataElementDataDimensionItems(List cocIds, long newCocId) { + if (cocIds.isEmpty()) return 0; + + String sql = + """ + update datadimensionitem + set dataelementoperand_categoryoptioncomboid = %s + where dataelementoperand_categoryoptioncomboid in (%s) + """ + .formatted( + newCocId, cocIds.stream().map(String::valueOf).collect(Collectors.joining(","))); + return jdbcTemplate.update(sql); + } } diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/dataelement/hibernate/HibernateDataElementOperandStore.java b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/dataelement/hibernate/HibernateDataElementOperandStore.java index f43fcac8ca51..8970385f074f 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/dataelement/hibernate/HibernateDataElementOperandStore.java +++ b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/dataelement/hibernate/HibernateDataElementOperandStore.java @@ -31,6 +31,7 @@ import java.util.Collection; import java.util.List; import javax.annotation.Nonnull; +import org.hisp.dhis.common.UID; import org.hisp.dhis.common.hibernate.HibernateIdentifiableObjectStore; import org.hisp.dhis.dataelement.DataElement; import org.hisp.dhis.dataelement.DataElementOperand; @@ -79,4 +80,17 @@ public List getByDataElement(Collection dataEle .setParameter("dataElements", dataElements) .list(); } + + @Override + public List getByCategoryOptionCombo(@Nonnull Collection uids) { + if (uids.isEmpty()) return List.of(); + return getQuery( + """ + select distinct deo from DataElementOperand deo + join deo.categoryOptionCombo coc + where coc.uid in :uids + """) + .setParameter("uids", UID.toValueList(uids)) + .getResultList(); + } } diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/dataset/hibernate/HibernateCompleteDataSetRegistrationStore.java b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/dataset/hibernate/HibernateCompleteDataSetRegistrationStore.java index c89cc4c00b59..f265f1c6324f 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/dataset/hibernate/HibernateCompleteDataSetRegistrationStore.java +++ b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/dataset/hibernate/HibernateCompleteDataSetRegistrationStore.java @@ -33,9 +33,12 @@ import jakarta.persistence.criteria.CriteriaBuilder; import jakarta.persistence.criteria.CriteriaQuery; import jakarta.persistence.criteria.Root; +import java.util.Collection; import java.util.Date; import java.util.List; +import javax.annotation.Nonnull; import org.hisp.dhis.category.CategoryOptionCombo; +import org.hisp.dhis.common.UID; import org.hisp.dhis.dataset.CompleteDataSetRegistration; import org.hisp.dhis.dataset.CompleteDataSetRegistrationStore; import org.hisp.dhis.dataset.DataSet; @@ -80,6 +83,12 @@ public void saveCompleteDataSetRegistration(CompleteDataSetRegistration registra getSession().save(registration); } + @Override + public void saveWithoutUpdatingLastUpdated(@Nonnull CompleteDataSetRegistration registration) { + registration.setPeriod(periodStore.reloadForceAddPeriod(registration.getPeriod())); + getSession().save(registration); + } + @Override public void updateCompleteDataSetRegistration(CompleteDataSetRegistration registration) { registration.setPeriod(periodStore.reloadForceAddPeriod(registration.getPeriod())); @@ -154,4 +163,32 @@ public int getCompleteDataSetCountLastUpdatedAfter(Date lastUpdated) { return Math.toIntExact(entityManager.createQuery(query).getSingleResult()); } + + @Override + public List getAllByCategoryOptionCombo( + @Nonnull Collection uids) { + if (uids.isEmpty()) return List.of(); + return getQuery( + """ + select cdsr from CompleteDataSetRegistration cdsr + join cdsr.attributeOptionCombo aoc + where aoc.uid in :uids + """) + .setParameter("uids", UID.toValueList(uids)) + .getResultList(); + } + + @Override + public void deleteByCategoryOptionCombo(@Nonnull Collection cocs) { + if (cocs.isEmpty()) return; + String hql = + """ + delete from CompleteDataSetRegistration cdsr + where cdsr.attributeOptionCombo in + (select coc from CategoryOptionCombo coc + where coc in :cocs) + """; + + entityManager.createQuery(hql).setParameter("cocs", cocs).executeUpdate(); + } } diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/datavalue/DataValueUtil.java b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/datavalue/DataValueUtil.java new file mode 100644 index 000000000000..8f844f2a5d5d --- /dev/null +++ b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/datavalue/DataValueUtil.java @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2004-2022, 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: + * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 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. + * Neither the name of the HISP project 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.datavalue; + +import java.util.function.BiFunction; +import org.hisp.dhis.category.CategoryOptionCombo; +import org.hisp.dhis.common.BaseIdentifiableObject; +import org.hisp.dhis.dataelement.DataElement; + +public final class DataValueUtil { + + private DataValueUtil() {} + + /** + * Creates and returns a new {@link DataValue}. All the old values are used from the supplied old + * {@link DataValue} except for the {@link DataElement} field, which uses the supplied {@link + * DataElement}. + */ + public static final BiFunction + dataValueWithNewDataElement = + (oldDv, newDataElement) -> { + DataValue newValue = + DataValue.builder() + .dataElement((DataElement) newDataElement) + .period(oldDv.getPeriod()) + .source(oldDv.getSource()) + .categoryOptionCombo(oldDv.getCategoryOptionCombo()) + .attributeOptionCombo(oldDv.getAttributeOptionCombo()) + .value(oldDv.getValue()) + .storedBy(oldDv.getStoredBy()) + .lastUpdated(oldDv.getLastUpdated()) + .comment(oldDv.getComment()) + .followup(oldDv.isFollowup()) + .deleted(oldDv.isDeleted()) + .build(); + newValue.setCreated(oldDv.getCreated()); + return newValue; + }; + + /** + * Creates and returns a new {@link DataValue}. All the old values are used from the supplied old + * {@link DataValue} except for the {@link CategoryOptionCombo} field, which uses the supplied + * {@link CategoryOptionCombo}. + */ + public static final BiFunction + dataValueWithNewCatOptionCombo = + (oldDv, newCoc) -> { + DataValue newValue = + DataValue.builder() + .dataElement(oldDv.getDataElement()) + .period(oldDv.getPeriod()) + .source(oldDv.getSource()) + .categoryOptionCombo((CategoryOptionCombo) newCoc) + .attributeOptionCombo(oldDv.getAttributeOptionCombo()) + .value(oldDv.getValue()) + .storedBy(oldDv.getStoredBy()) + .lastUpdated(oldDv.getLastUpdated()) + .comment(oldDv.getComment()) + .followup(oldDv.isFollowup()) + .deleted(oldDv.isDeleted()) + .build(); + newValue.setCreated(oldDv.getCreated()); + return newValue; + }; + + /** + * Creates and returns a new {@link DataValue}. All the old values are used from the supplied old + * {@link DataValue} except for the attributeOptionCombo} field, which uses the supplied {@link + * CategoryOptionCombo}. + */ + public static final BiFunction + dataValueWithNewAttrOptionCombo = + (oldDv, newAoc) -> { + DataValue newValue = + DataValue.builder() + .dataElement(oldDv.getDataElement()) + .period(oldDv.getPeriod()) + .source(oldDv.getSource()) + .categoryOptionCombo(oldDv.getCategoryOptionCombo()) + .attributeOptionCombo((CategoryOptionCombo) newAoc) + .value(oldDv.getValue()) + .storedBy(oldDv.getStoredBy()) + .lastUpdated(oldDv.getLastUpdated()) + .comment(oldDv.getComment()) + .followup(oldDv.isFollowup()) + .deleted(oldDv.isDeleted()) + .build(); + newValue.setCreated(oldDv.getCreated()); + return newValue; + }; +} diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/datavalue/hibernate/HibernateDataValueAuditStore.java b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/datavalue/hibernate/HibernateDataValueAuditStore.java index dab7ac29f2b9..283d96cfdca4 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/datavalue/hibernate/HibernateDataValueAuditStore.java +++ b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/datavalue/hibernate/HibernateDataValueAuditStore.java @@ -36,6 +36,8 @@ import java.util.ArrayList; import java.util.List; import java.util.function.Function; +import javax.annotation.Nonnull; +import org.hisp.dhis.category.CategoryOptionCombo; import org.hisp.dhis.dataelement.DataElement; import org.hisp.dhis.datavalue.DataValueAudit; import org.hisp.dhis.datavalue.DataValueAuditQueryParams; @@ -104,6 +106,16 @@ public void deleteDataValueAudits(DataElement dataElement) { entityManager.createQuery(hql).setParameter("dataElement", dataElement).executeUpdate(); } + @Override + public void deleteDataValueAudits(@Nonnull CategoryOptionCombo categoryOptionCombo) { + String hql = + "delete from DataValueAudit d where d.categoryOptionCombo = :categoryOptionCombo or d.attributeOptionCombo = :categoryOptionCombo"; + entityManager + .createQuery(hql) + .setParameter("categoryOptionCombo", categoryOptionCombo) + .executeUpdate(); + } + @Override public List getDataValueAudits(DataValueAuditQueryParams params) { CriteriaBuilder builder = entityManager.getCriteriaBuilder(); diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/datavalue/hibernate/HibernateDataValueStore.java b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/datavalue/hibernate/HibernateDataValueStore.java index 19a6107ff8d1..d4f026b62a20 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/datavalue/hibernate/HibernateDataValueStore.java +++ b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/datavalue/hibernate/HibernateDataValueStore.java @@ -51,10 +51,13 @@ import java.util.Set; import java.util.concurrent.BlockingQueue; import java.util.function.Function; +import java.util.stream.Collectors; +import javax.annotation.Nonnull; import lombok.extern.slf4j.Slf4j; import org.hibernate.query.Query; import org.hisp.dhis.category.CategoryCombo; import org.hisp.dhis.category.CategoryOptionCombo; +import org.hisp.dhis.common.UID; import org.hisp.dhis.commons.util.SqlHelper; import org.hisp.dhis.dataelement.DataElement; import org.hisp.dhis.datavalue.DataExportParams; @@ -129,6 +132,35 @@ public void deleteDataValues(DataElement dataElement) { entityManager.createQuery(hql).setParameter("dataElement", dataElement).executeUpdate(); } + @Override + public void deleteDataValues(@Nonnull Collection dataElements) { + String hql = "delete from DataValue d where d.dataElement in :dataElements"; + + entityManager.createQuery(hql).setParameter("dataElements", dataElements).executeUpdate(); + } + + @Override + public void deleteDataValuesByCategoryOptionCombo( + @Nonnull Collection categoryOptionCombos) { + String hql = "delete from DataValue d where d.categoryOptionCombo in :categoryOptionCombos"; + + entityManager + .createQuery(hql) + .setParameter("categoryOptionCombos", categoryOptionCombos) + .executeUpdate(); + } + + @Override + public void deleteDataValuesByAttributeOptionCombo( + @Nonnull Collection attributeOptionCombos) { + String hql = "delete from DataValue d where d.attributeOptionCombo in :attributeOptionCombos"; + + entityManager + .createQuery(hql) + .setParameter("attributeOptionCombos", attributeOptionCombos) + .executeUpdate(); + } + @Override public void deleteDataValue(DataValue dataValue) { getQuery("delete from DataValue dv where dv = :dataValue") @@ -276,6 +308,206 @@ public boolean dataValueExistsForDataElement(String uid) { .isEmpty(); } + @Override + public List getAllDataValuesByCatOptCombo(@Nonnull Collection uids) { + if (uids.isEmpty()) return List.of(); + return getQuery( + """ + select dv from DataValue dv + join dv.categoryOptionCombo coc + where coc.uid in :uids + """) + .setParameter("uids", UID.toValueList(uids)) + .getResultList(); + } + + @Override + public List getAllDataValuesByAttrOptCombo(@Nonnull Collection uids) { + if (uids.isEmpty()) return List.of(); + return getQuery( + """ + select dv from DataValue dv + join dv.attributeOptionCombo aoc + where aoc.uid in :uids + """) + .setParameter("uids", UID.toValueList(uids)) + .getResultList(); + } + + @Override + public void mergeDataValuesWithCategoryOptionCombos( + @Nonnull CategoryOptionCombo target, @Nonnull Collection sources) { + String plpgsql = + """ + do + $$ + declare + source_dv record; + target_duplicate record; + target_coc bigint default %s; + begin + + -- loop through each record with a source CategoryOptionCombo + for source_dv in + select * from datavalue where categoryoptioncomboid in (%s) + loop + + -- check if target Data Value exists with same unique key + select dv.* + into target_duplicate + from datavalue dv + where dv.dataelementid = source_dv.dataelementid + and dv.periodid = source_dv.periodid + and dv.sourceid = source_dv.sourceid + and dv.attributeoptioncomboid = source_dv.attributeoptioncomboid + and dv.categoryoptioncomboid = target_coc; + + -- target duplicate found and target has latest lastUpdated value + if (target_duplicate.categoryoptioncomboid is not null + and target_duplicate.lastupdated >= source_dv.lastupdated) + then + -- delete source + delete from datavalue + where dataelementid = source_dv.dataelementid + and periodid = source_dv.periodid + and sourceid = source_dv.sourceid + and attributeoptioncomboid = source_dv.attributeoptioncomboid + and categoryoptioncomboid = source_dv.categoryoptioncomboid; + + -- target duplicate found and source has latest lastUpdated value + elsif (target_duplicate.categoryoptioncomboid is not null + and target_duplicate.lastupdated < source_dv.lastupdated) + then + -- delete target + delete from datavalue + where dataelementid = target_duplicate.dataelementid + and periodid = target_duplicate.periodid + and sourceid = target_duplicate.sourceid + and attributeoptioncomboid = target_duplicate.attributeoptioncomboid + and categoryoptioncomboid = target_duplicate.categoryoptioncomboid; + + -- update source with target CategoryOptionCombo + update datavalue + set categoryoptioncomboid = target_coc + where dataelementid = source_dv.dataelementid + and periodid = source_dv.periodid + and sourceid = source_dv.sourceid + and attributeoptioncomboid = source_dv.attributeoptioncomboid + and categoryoptioncomboid = source_dv.categoryoptioncomboid; + + else + -- no target duplicate found, update source with target CategoryOptionCombo + update datavalue + set categoryoptioncomboid = target_coc + where dataelementid = source_dv.dataelementid + and periodid = source_dv.periodid + and sourceid = source_dv.sourceid + and attributeoptioncomboid = source_dv.attributeoptioncomboid + and categoryoptioncomboid = source_dv.categoryoptioncomboid; + + end if; + + end loop; + end; + $$ + language plpgsql; + """ + .formatted( + target.getId(), + sources.stream() + .map(s -> String.valueOf(s.getId())) + .collect(Collectors.joining(","))); + + jdbcTemplate.update(plpgsql); + } + + @Override + public void mergeDataValuesWithAttributeOptionCombos( + @Nonnull CategoryOptionCombo target, @Nonnull Collection sources) { + String plpgsql = + """ + do + $$ + declare + source_dv record; + target_duplicate record; + target_aoc bigint default %s; + begin + + -- loop through each record with a source Attribute Option Combo + for source_dv in + select * from datavalue where attributeoptioncomboid in (%s) + loop + + -- check if target DataValue exists with same unique key + select dv.* + into target_duplicate + from datavalue dv + where dv.dataelementid = source_dv.dataelementid + and dv.periodid = source_dv.periodid + and dv.sourceid = source_dv.sourceid + and dv.attributeoptioncomboid = target_aoc + and dv.categoryoptioncomboid = source_dv.categoryoptioncomboid; + + -- target duplicate found and target has latest lastUpdated value + if (target_duplicate.attributeoptioncomboid is not null + and target_duplicate.lastupdated >= source_dv.lastupdated) + then + -- delete source + delete from datavalue + where dataelementid = source_dv.dataelementid + and periodid = source_dv.periodid + and sourceid = source_dv.sourceid + and attributeoptioncomboid = source_dv.attributeoptioncomboid + and categoryoptioncomboid = source_dv.categoryoptioncomboid; + + -- target duplicate found and source has latest lastUpdated value + elsif (target_duplicate.attributeoptioncomboid is not null + and target_duplicate.lastupdated < source_dv.lastupdated) + then + -- delete target + delete from datavalue + where dataelementid = target_duplicate.dataelementid + and periodid = target_duplicate.periodid + and sourceid = target_duplicate.sourceid + and attributeoptioncomboid = target_duplicate.attributeoptioncomboid + and categoryoptioncomboid = target_duplicate.categoryoptioncomboid; + + -- update source with target Attribute Option Combo + update datavalue + set attributeoptioncomboid = target_duplicate.attributeoptioncomboid + where dataelementid = source_dv.dataelementid + and periodid = source_dv.periodid + and sourceid = source_dv.sourceid + and attributeoptioncomboid = source_dv.attributeoptioncomboid + and categoryoptioncomboid = source_dv.categoryoptioncomboid; + + else + -- no target duplicate found, update source with target Attribute Option Combo + update datavalue + SET attributeoptioncomboid = target_aoc + where dataelementid = source_dv.dataelementid + and periodid = source_dv.periodid + and sourceid = source_dv.sourceid + and attributeoptioncomboid = source_dv.attributeoptioncomboid + and categoryoptioncomboid = source_dv.categoryoptioncomboid; + + end if; + + end loop; + end; + $$ + language plpgsql; + """ + .formatted( + target.getId(), + sources.stream() + .map(s -> String.valueOf(s.getId())) + .collect(Collectors.joining(","))); + + jdbcTemplate.update(plpgsql); + } + // ------------------------------------------------------------------------- // getDataValues and related supportive methods // ------------------------------------------------------------------------- diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/predictor/hibernate/HibernatePredictorStore.java b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/predictor/hibernate/HibernatePredictorStore.java index d88cce1894c0..e1e2069a2fad 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/predictor/hibernate/HibernatePredictorStore.java +++ b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/predictor/hibernate/HibernatePredictorStore.java @@ -33,6 +33,7 @@ import java.util.Collection; import java.util.List; import javax.annotation.Nonnull; +import org.hisp.dhis.common.UID; import org.hisp.dhis.common.hibernate.HibernateIdentifiableObjectStore; import org.hisp.dhis.dataelement.DataElement; import org.hisp.dhis.period.PeriodService; @@ -125,4 +126,17 @@ public List getAllWithSampleSkipTestContainingDataElement( .formatted(multiLike)) .getResultList(); } + + @Override + public List getByCategoryOptionCombo(@Nonnull Collection uids) { + if (uids.isEmpty()) return List.of(); + return getQuery( + """ + select distinct p from Predictor p + join p.outputCombo coc + where coc.uid in :uids + """) + .setParameter("uids", UID.toValueList(uids)) + .list(); + } } diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/program/hibernate/HibernateEventStore.java b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/program/hibernate/HibernateEventStore.java index 87a578577bc2..06670fa25518 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/program/hibernate/HibernateEventStore.java +++ b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/program/hibernate/HibernateEventStore.java @@ -33,7 +33,9 @@ import jakarta.persistence.criteria.Predicate; import jakarta.persistence.criteria.Root; import java.util.List; +import java.util.Set; import java.util.function.Function; +import java.util.stream.Collectors; import org.hisp.dhis.common.UID; import org.hisp.dhis.common.hibernate.SoftDeleteHibernateObjectStore; import org.hisp.dhis.dataelement.DataElement; @@ -88,4 +90,18 @@ where jsonb_exists_any(e.eventdatavalues, :searchStrings) "searchStrings", searchStrings.toArray(String[]::new), StringArrayType.INSTANCE) .getResultList(); } + + @Override + public void setAttributeOptionCombo(Set cocs, long coc) { + if (cocs.isEmpty()) return; + String sql = + """ + update event + set attributeoptioncomboid = %s + where attributeoptioncomboid in (%s) + """ + .formatted(coc, cocs.stream().map(String::valueOf).collect(Collectors.joining(","))); + + entityManager.createNativeQuery(sql).executeUpdate(); + } } diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/scheduling/HibernateJobConfigurationStore.java b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/scheduling/HibernateJobConfigurationStore.java index d7db1f60b562..fbfb39ea9af1 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/scheduling/HibernateJobConfigurationStore.java +++ b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/scheduling/HibernateJobConfigurationStore.java @@ -514,6 +514,7 @@ public boolean tryRevertNow(@Nonnull String jobId) { else schedulingtype end where jobstatus = 'RUNNING' and uid = :id + and now() > jobconfiguration.lastalive + interval '1 minute' """; return nativeSynchronizedQuery(sql).setParameter("id", jobId).executeUpdate() > 0; } diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/sms/command/hibernate/HibernateSMSCommandStore.java b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/sms/command/hibernate/HibernateSMSCommandStore.java index c37bb17b3861..a90d9cda23f6 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/sms/command/hibernate/HibernateSMSCommandStore.java +++ b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/sms/command/hibernate/HibernateSMSCommandStore.java @@ -31,7 +31,9 @@ import jakarta.persistence.criteria.CriteriaBuilder; import java.util.Collection; import java.util.List; +import javax.annotation.Nonnull; import org.hibernate.query.Query; +import org.hisp.dhis.common.UID; import org.hisp.dhis.common.hibernate.HibernateIdentifiableObjectStore; import org.hisp.dhis.dataelement.DataElement; import org.hisp.dhis.dataset.DataSet; @@ -110,4 +112,18 @@ public List getCodesByDataElement(Collection dataElements) .setParameter("dataElements", dataElements) .list(); } + + @Override + public List getCodesByCategoryOptionCombo(@Nonnull Collection uids) { + if (uids.isEmpty()) return List.of(); + return getQuery( + """ + select distinct sms from SMSCode sms + join sms.optionId coc + where coc.uid in :uids + """, + SMSCode.class) + .setParameter("uids", UID.toValueList(uids)) + .list(); + } } diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/sms/command/hibernate/SMSCommandStore.java b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/sms/command/hibernate/SMSCommandStore.java index 0c295c37f74c..fa8d64ad9b97 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/sms/command/hibernate/SMSCommandStore.java +++ b/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/sms/command/hibernate/SMSCommandStore.java @@ -29,7 +29,9 @@ import java.util.Collection; import java.util.List; +import javax.annotation.Nonnull; import org.hisp.dhis.common.IdentifiableObjectStore; +import org.hisp.dhis.common.UID; import org.hisp.dhis.dataelement.DataElement; import org.hisp.dhis.dataset.DataSet; import org.hisp.dhis.sms.command.SMSCommand; @@ -44,4 +46,6 @@ public interface SMSCommandStore extends IdentifiableObjectStore { int countDataSetSmsCommands(DataSet dataSet); List getCodesByDataElement(Collection dataElements); + + List getCodesByCategoryOptionCombo(@Nonnull Collection uids); } diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/resources/i18n_global.properties b/dhis-2/dhis-services/dhis-service-core/src/main/resources/i18n_global.properties index 36217045a05e..d097174739d8 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/resources/i18n_global.properties +++ b/dhis-2/dhis-services/dhis-service-core/src/main/resources/i18n_global.properties @@ -164,6 +164,7 @@ F_CATEGORY_OPTION_MERGE=Merge Category Options F_CATEGORY_COMBO_PUBLIC_ADD=Add/Update Public Category Combo F_CATEGORY_COMBO_PRIVATE_ADD=Add/Update Private Category Combo F_CATEGORY_COMBO_DELETE=Delete Category Combo +F_CATEGORY_OPTION_COMBO_MERGE=Merge Category Option Combos F_CATEGORY_OPTION_GROUP_PUBLIC_ADD=Add/Update Public Category Option Group F_CATEGORY_OPTION_GROUP_PRIVATE_ADD=Add/Update Private Category Option Group F_CATEGORY_OPTION_GROUP_DELETE=Delete Category Option Group @@ -2130,6 +2131,7 @@ data_integrity.user_roles_with_no_users.name=User roles with no users data_integrity.option_groups_empty.users=Option groups with no options data_integrity.push_analysis_no_recipients.name=Push analyses without recipients data_integrity.file_resources_no_icon.name=File resources that are missing an icon +data_integrity.users_with_all_authority.name=Users with the ALL authority # -- End Data Integrity Checks--------------------------------------------# diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/dataelement/hibernate/DataElementOperand.hbm.xml b/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/dataelement/hibernate/DataElementOperand.hbm.xml index dd29470a73f3..df166643a1ab 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/dataelement/hibernate/DataElementOperand.hbm.xml +++ b/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/dataelement/hibernate/DataElementOperand.hbm.xml @@ -18,6 +18,7 @@ + diff --git a/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/datavalue/DataValueUtilTest.java b/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/datavalue/DataValueUtilTest.java new file mode 100644 index 000000000000..5755ea39d847 --- /dev/null +++ b/dhis-2/dhis-services/dhis-service-core/src/test/java/org/hisp/dhis/datavalue/DataValueUtilTest.java @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2004-2024, 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: + * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 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. + * Neither the name of the HISP project 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.datavalue; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import javax.annotation.Nonnull; +import org.hisp.dhis.category.CategoryOptionCombo; +import org.hisp.dhis.dataelement.DataElement; +import org.hisp.dhis.organisationunit.OrganisationUnit; +import org.hisp.dhis.period.Period; +import org.hisp.dhis.util.DateUtils; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class DataValueUtilTest { + + @Test + @DisplayName("Creates a new DataValue with the expected new category option combo") + void newDataValueWithCocTest() { + // given + DataValue originalDataValue = getDataValue(); + + // new category option combo to use + CategoryOptionCombo newCoc = new CategoryOptionCombo(); + newCoc.setName("New COC"); + + // when + DataValue newDataValue = + DataValueUtil.dataValueWithNewCatOptionCombo.apply(originalDataValue, newCoc); + + // then + assertWithNewValue(newDataValue, "New COC", "COC"); + } + + @Test + @DisplayName("Creates a new DataValue with the expected new attribute option combo") + void newDataValueWithAocTest() { + // given + DataValue originalDataValue = getDataValue(); + + // new attribute option combo to use + CategoryOptionCombo newAoc = new CategoryOptionCombo(); + newAoc.setName("New AOC"); + + // when + DataValue newDataValue = + DataValueUtil.dataValueWithNewAttrOptionCombo.apply(originalDataValue, newAoc); + + // then + assertWithNewValue(newDataValue, "New AOC", "AOC"); + } + + @Test + @DisplayName("Creates a new DataValue with the expected new data element") + void newDataValueWithDeTest() { + // given + DataValue originalDataValue = getDataValue(); + + // new attribute option combo to use + DataElement newDe = new DataElement("New DE"); + + // when + DataValue newDataValue = + DataValueUtil.dataValueWithNewDataElement.apply(originalDataValue, newDe); + + // then + assertWithNewValue(newDataValue, "New DE", "DE"); + } + + private void assertWithNewValue( + @Nonnull DataValue dv, @Nonnull String newValue, @Nonnull String property) { + if (property.equals("DE")) { + assertEquals(newValue, dv.getDataElement().getName()); + } else assertEquals("test DE", dv.getDataElement().getName()); + assertEquals("test Period", dv.getPeriod().getName()); + assertEquals("test Org Unit", dv.getSource().getName()); + if (property.equals("COC")) { + assertEquals(newValue, dv.getCategoryOptionCombo().getName()); + } else assertEquals("test COC", dv.getCategoryOptionCombo().getName()); + if (property.equals("AOC")) { + assertEquals(newValue, dv.getAttributeOptionCombo().getName()); + } else assertEquals("test AOC", dv.getAttributeOptionCombo().getName()); + assertEquals("test value", dv.getValue()); + assertEquals("test user", dv.getStoredBy()); + assertEquals(DateUtils.toMediumDate("2024-11-15"), dv.getLastUpdated()); + assertEquals("test comment", dv.getComment()); + assertTrue(dv.isFollowup()); + assertTrue(dv.isDeleted()); + assertEquals(DateUtils.toMediumDate("2024-07-25"), dv.getCreated()); + } + + private DataValue getDataValue() { + DataElement dataElement = new DataElement("test DE"); + Period period = new Period(); + period.setName("test Period"); + OrganisationUnit orgUnit = new OrganisationUnit("test Org Unit"); + CategoryOptionCombo coc = new CategoryOptionCombo(); + coc.setName("test COC"); + CategoryOptionCombo aoc = new CategoryOptionCombo(); + aoc.setName("test AOC"); + + DataValue dv = + DataValue.builder() + .dataElement(dataElement) + .period(period) + .source(orgUnit) + .categoryOptionCombo(coc) + .attributeOptionCombo(aoc) + .value("test value") + .storedBy("test user") + .lastUpdated(DateUtils.toMediumDate("2024-11-15")) + .comment("test comment") + .followup(true) + .deleted(true) + .build(); + dv.setCreated(DateUtils.toMediumDate("2024-07-25")); + return dv; + } +} diff --git a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/trackedentity/DefaultTrackedEntityService.java b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/trackedentity/DefaultTrackedEntityService.java index c9f500a60318..0996d6c512d0 100644 --- a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/trackedentity/DefaultTrackedEntityService.java +++ b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/trackedentity/DefaultTrackedEntityService.java @@ -30,7 +30,6 @@ import static org.hisp.dhis.audit.AuditOperationType.READ; import static org.hisp.dhis.audit.AuditOperationType.SEARCH; import static org.hisp.dhis.user.CurrentUserUtil.getCurrentUserDetails; -import static org.hisp.dhis.user.CurrentUserUtil.getCurrentUsername; import java.util.HashSet; import java.util.List; @@ -44,6 +43,7 @@ import org.hisp.dhis.common.BaseIdentifiableObject; import org.hisp.dhis.common.IdentifiableObject; import org.hisp.dhis.common.UID; +import org.hisp.dhis.common.collection.CollectionUtils; import org.hisp.dhis.feedback.BadRequestException; import org.hisp.dhis.feedback.ForbiddenException; import org.hisp.dhis.feedback.NotFoundException; @@ -56,6 +56,7 @@ import org.hisp.dhis.program.ProgramService; import org.hisp.dhis.relationship.Relationship; import org.hisp.dhis.relationship.RelationshipItem; +import org.hisp.dhis.security.acl.AclService; import org.hisp.dhis.trackedentity.TrackedEntity; import org.hisp.dhis.trackedentity.TrackedEntityAttribute; import org.hisp.dhis.trackedentity.TrackedEntityAttributeService; @@ -63,6 +64,7 @@ import org.hisp.dhis.trackedentity.TrackedEntityProgramOwner; import org.hisp.dhis.trackedentity.TrackedEntityType; import org.hisp.dhis.trackedentity.TrackedEntityTypeService; +import org.hisp.dhis.trackedentity.TrackedEntityTypeStore; import org.hisp.dhis.trackedentityattributevalue.TrackedEntityAttributeValue; import org.hisp.dhis.tracker.acl.TrackerAccessManager; import org.hisp.dhis.tracker.audit.TrackedEntityAuditService; @@ -88,7 +90,9 @@ class DefaultTrackedEntityService implements TrackedEntityService { private final TrackedEntityAttributeService trackedEntityAttributeService; + private final TrackedEntityTypeStore trackedEntityTypeStore; private final TrackedEntityTypeService trackedEntityTypeService; + private final AclService aclService; private final TrackedEntityAuditService trackedEntityAuditService; @@ -232,10 +236,7 @@ public TrackedEntity getTrackedEntity( } } - UserDetails userDetails = getCurrentUserDetails(); - TrackedEntity trackedEntity = getTrackedEntity(trackedEntityUid, userDetails, program); - trackedEntity = mapTrackedEntity(trackedEntity, params, userDetails, program, false); - return trackedEntity; + return getTrackedEntity(trackedEntityUid, program, params, getCurrentUserDetails()); } /** @@ -245,87 +246,44 @@ public TrackedEntity getTrackedEntity( * @throws NotFoundException if uid does not exist * @throws ForbiddenException if TE owner is not in user's scope or not enough sharing access */ - private TrackedEntity getTrackedEntity(UID uid, UserDetails userDetails, Program program) + private TrackedEntity getTrackedEntity( + UID uid, Program program, TrackedEntityParams params, UserDetails user) throws NotFoundException, ForbiddenException { TrackedEntity trackedEntity = trackedEntityStore.getByUid(uid.getValue()); - trackedEntityAuditService.addTrackedEntityAudit(trackedEntity, getCurrentUsername(), READ); if (trackedEntity == null) { throw new NotFoundException(TrackedEntity.class, uid); } + trackedEntityAuditService.addTrackedEntityAudit(trackedEntity, user.getUsername(), READ); + if (program != null) { List errors = - trackerAccessManager.canReadProgramAndTrackedEntityType( - userDetails, trackedEntity, program); + trackerAccessManager.canReadProgramAndTrackedEntityType(user, trackedEntity, program); if (!errors.isEmpty()) { throw new ForbiddenException(errors.toString()); } String error = - trackerAccessManager.canAccessProgramOwner(userDetails, trackedEntity, program, false); + trackerAccessManager.canAccessProgramOwner(user, trackedEntity, program, false); if (error != null) { throw new ForbiddenException(error); } } else { - if (!trackerAccessManager.canRead(userDetails, trackedEntity).isEmpty()) { + if (!trackerAccessManager.canRead(user, trackedEntity).isEmpty()) { throw new ForbiddenException(TrackedEntity.class, uid); } } - return trackedEntity; - } - - private TrackedEntity mapTrackedEntity( - TrackedEntity trackedEntity, - TrackedEntityParams params, - UserDetails user, - Program program, - boolean includeDeleted) { - TrackedEntity result = new TrackedEntity(); - result.setId(trackedEntity.getId()); - result.setUid(trackedEntity.getUid()); - result.setOrganisationUnit(trackedEntity.getOrganisationUnit()); - result.setTrackedEntityType(trackedEntity.getTrackedEntityType()); - result.setCreated(trackedEntity.getCreated()); - result.setCreatedAtClient(trackedEntity.getCreatedAtClient()); - result.setLastUpdated(trackedEntity.getLastUpdated()); - result.setLastUpdatedAtClient(trackedEntity.getLastUpdatedAtClient()); - result.setInactive(trackedEntity.isInactive()); - result.setGeometry(trackedEntity.getGeometry()); - result.setDeleted(trackedEntity.isDeleted()); - result.setPotentialDuplicate(trackedEntity.isPotentialDuplicate()); - result.setStoredBy(trackedEntity.getStoredBy()); - result.setCreatedByUserInfo(trackedEntity.getCreatedByUserInfo()); - result.setLastUpdatedByUserInfo(trackedEntity.getLastUpdatedByUserInfo()); - result.setGeometry(trackedEntity.getGeometry()); - if (params.isIncludeRelationships()) { - result.setRelationshipItems(getRelationshipItems(trackedEntity, user, includeDeleted)); - } if (params.isIncludeEnrollments()) { - result.setEnrollments(getEnrollments(trackedEntity, user, includeDeleted, program)); + trackedEntity.setEnrollments(getEnrollments(trackedEntity, user, false, program)); } + setRelationshipItems(trackedEntity, trackedEntity, params, false); if (params.isIncludeProgramOwners()) { - result.setProgramOwners(getTrackedEntityProgramOwners(trackedEntity, program)); - } - - result.setTrackedEntityAttributeValues(getTrackedEntityAttributeValues(trackedEntity, program)); - - return result; - } - - private Set getRelationshipItems( - TrackedEntity trackedEntity, UserDetails user, boolean includeDeleted) { - Set items = new HashSet<>(); - - for (RelationshipItem relationshipItem : trackedEntity.getRelationshipItems()) { - Relationship daoRelationship = relationshipItem.getRelationship(); - - if (trackerAccessManager.canRead(user, daoRelationship).isEmpty() - && (includeDeleted || !daoRelationship.isDeleted())) { - items.add(relationshipItem); - } + trackedEntity.setProgramOwners(getTrackedEntityProgramOwners(trackedEntity, program)); } - return items; + trackedEntity.setTrackedEntityAttributeValues( + getTrackedEntityAttributeValues(trackedEntity, program)); + return trackedEntity; } private Set getEnrollments( @@ -362,8 +320,17 @@ private static Set getTrackedEntityProgramOwners( private Set getTrackedEntityAttributeValues( TrackedEntity trackedEntity, Program program) { + TrackedEntityType trackedEntityType = trackedEntity.getTrackedEntityType(); + if (CollectionUtils.isEmpty(trackedEntityType.getTrackedEntityTypeAttributes())) { + // the TrackedEntityAggregate does not fetch the TrackedEntityTypeAttributes at the moment + // TODO(DHIS2-18541) bypass ACL as our controller test as the user must have access to the TET + // if it has access to the TE. + trackedEntityType = + trackedEntityTypeStore.getByUidNoAcl(trackedEntity.getTrackedEntityType().getUid()); + } + Set teas = // tracked entity type attributes - trackedEntity.getTrackedEntityType().getTrackedEntityAttributes().stream() + trackedEntityType.getTrackedEntityAttributes().stream() .map(IdentifiableObject::getUid) .collect(Collectors.toSet()); if (program != null) { // add program tracked entity attributes @@ -377,74 +344,14 @@ private Set getTrackedEntityAttributeValues( .collect(Collectors.toSet()); } - private RelationshipItem withNestedEntity( - TrackedEntity trackedEntity, RelationshipItem item, boolean includeDeleted) - throws NotFoundException { - // relationships of relationship items are not mapped to JSON so there is no need to fetch them - RelationshipItem result = new RelationshipItem(); - - if (item.getTrackedEntity() != null) { - if (trackedEntity.getUid().equals(item.getTrackedEntity().getUid())) { - // only fetch the TE if we do not already have access to it. meaning the TE owns the item - // this is just mapping the TE - result.setTrackedEntity(trackedEntity); - } else { - result = - getTrackedEntityInRelationshipItem( - item.getTrackedEntity().getUid(), - TrackedEntityParams.TRUE.withIncludeRelationships(false), - includeDeleted); - } - } else if (item.getEnrollment() != null) { - result = - enrollmentService.getEnrollmentInRelationshipItem( - UID.of(item.getEnrollment()), - EnrollmentParams.TRUE.withIncludeRelationships(false), - false); - } else if (item.getEvent() != null) { - result = - eventService.getEventInRelationshipItem( - UID.of(item.getEvent()), EventParams.TRUE.withIncludeRelationships(false)); - } - - return result; - } - - /** - * Gets a tracked entity that's part of a relationship item. This method is meant to be used when - * fetching relationship items only, because it won't throw an exception if the TE is not - * accessible. - * - * @return the TE object if found and accessible by the current user or null otherwise - * @throws NotFoundException if uid does not exist - */ - private RelationshipItem getTrackedEntityInRelationshipItem( - String uid, TrackedEntityParams params, boolean includeDeleted) throws NotFoundException { - RelationshipItem relationshipItem = new RelationshipItem(); - - TrackedEntity trackedEntity = trackedEntityStore.getByUid(uid); - trackedEntityAuditService.addTrackedEntityAudit(trackedEntity, getCurrentUsername(), READ); - if (trackedEntity == null) { - throw new NotFoundException(TrackedEntity.class, uid); - } - UserDetails currentUser = getCurrentUserDetails(); - - if (!trackerAccessManager.canRead(currentUser, trackedEntity).isEmpty()) { - return null; - } - - relationshipItem.setTrackedEntity( - mapTrackedEntity(trackedEntity, params, currentUser, null, includeDeleted)); - return relationshipItem; - } - @Nonnull @Override public List getTrackedEntities( @Nonnull TrackedEntityOperationParams operationParams) throws ForbiddenException, NotFoundException, BadRequestException { - TrackedEntityQueryParams queryParams = mapper.map(operationParams, getCurrentUserDetails()); - final List ids = getTrackedEntityIds(queryParams); + UserDetails user = getCurrentUserDetails(); + TrackedEntityQueryParams queryParams = mapper.map(operationParams, user); + final List ids = trackedEntityStore.getTrackedEntityIds(queryParams); List trackedEntities = this.trackedEntityAggregate.find( @@ -452,11 +359,14 @@ public List getTrackedEntities( operationParams.getTrackedEntityParams(), queryParams, operationParams.getOrgUnitMode()); - - mapRelationshipItems( + setRelationshipItems( trackedEntities, operationParams.getTrackedEntityParams(), operationParams.isIncludeDeleted()); + for (TrackedEntity trackedEntity : trackedEntities) { + trackedEntity.setTrackedEntityAttributeValues( + getTrackedEntityAttributeValues(trackedEntity, queryParams.getProgram())); + } addSearchAudit(trackedEntities); @@ -467,8 +377,9 @@ public List getTrackedEntities( public @Nonnull Page getTrackedEntities( @Nonnull TrackedEntityOperationParams operationParams, @Nonnull PageParams pageParams) throws BadRequestException, ForbiddenException, NotFoundException { - TrackedEntityQueryParams queryParams = mapper.map(operationParams, getCurrentUserDetails()); - final Page ids = getTrackedEntityIds(queryParams, pageParams); + UserDetails user = getCurrentUserDetails(); + TrackedEntityQueryParams queryParams = mapper.map(operationParams, user); + final Page ids = trackedEntityStore.getTrackedEntityIds(queryParams, pageParams); List trackedEntities = this.trackedEntityAggregate.find( @@ -477,102 +388,128 @@ public List getTrackedEntities( queryParams, operationParams.getOrgUnitMode()); - mapRelationshipItems( + setRelationshipItems( trackedEntities, operationParams.getTrackedEntityParams(), operationParams.isIncludeDeleted()); + for (TrackedEntity trackedEntity : trackedEntities) { + getTrackedEntityAttributeValues(trackedEntity, queryParams.getProgram()); + } addSearchAudit(trackedEntities); return ids.withItems(trackedEntities); } - private List getTrackedEntityIds(TrackedEntityQueryParams params) { - return trackedEntityStore.getTrackedEntityIds(params); - } - - private Page getTrackedEntityIds(TrackedEntityQueryParams params, PageParams pageParams) { - return trackedEntityStore.getTrackedEntityIds(params, pageParams); - } - /** * We need to return the full models for relationship items (i.e. trackedEntity, enrollment and * event) in our API. The aggregate stores currently do not support that, so we need to fetch the * entities individually. */ - private void mapRelationshipItems( + private void setRelationshipItems( List trackedEntities, TrackedEntityParams params, boolean includeDeleted) throws NotFoundException { + for (TrackedEntity trackedEntity : trackedEntities) { + setRelationshipItems(trackedEntity, trackedEntity, params, includeDeleted); + } + } + + private void setRelationshipItems( + TrackedEntity targetTrackedEntity, + TrackedEntity sourceTrackedEntity, + TrackedEntityParams params, + boolean includeDeleted) + throws NotFoundException { if (params.isIncludeRelationships()) { - for (TrackedEntity trackedEntity : trackedEntities) { - mapRelationshipItems(trackedEntity, includeDeleted); - } + targetTrackedEntity.setRelationshipItems( + getRelationshipItems(sourceTrackedEntity, includeDeleted)); } if (params.getEnrollmentParams().isIncludeRelationships()) { - for (TrackedEntity trackedEntity : trackedEntities) { - for (Enrollment enrollment : trackedEntity.getEnrollments()) { - mapRelationshipItems(enrollment, trackedEntity, includeDeleted); - } - } - } - if (params.getEventParams().isIncludeRelationships()) { - for (TrackedEntity trackedEntity : trackedEntities) { - for (Enrollment enrollment : trackedEntity.getEnrollments()) { - for (Event event : enrollment.getEvents()) { - mapRelationshipItems(event, trackedEntity, includeDeleted); + for (Enrollment sourceEnrollment : sourceTrackedEntity.getEnrollments()) { + for (Enrollment targetEnrollment : targetTrackedEntity.getEnrollments()) { + if (sourceEnrollment.getUid().equals(targetEnrollment.getUid())) { + targetEnrollment.setRelationshipItems( + getRelationshipItems(sourceEnrollment, sourceTrackedEntity, includeDeleted)); + + if (params.getEventParams().isIncludeRelationships()) { + for (Event sourceEvent : sourceEnrollment.getEvents()) { + for (Event targetEvent : targetEnrollment.getEvents()) { + if (targetEvent.getUid().equals(sourceEvent.getUid())) { + targetEvent.setRelationshipItems( + getRelationshipItems(sourceEvent, sourceTrackedEntity, includeDeleted)); + } + } + } + } } } } } } - private void mapRelationshipItems(TrackedEntity trackedEntity, boolean includeDeleted) - throws NotFoundException { + private Set getRelationshipItems( + TrackedEntity trackedEntity, boolean includeDeleted) throws NotFoundException { Set result = new HashSet<>(); for (RelationshipItem item : trackedEntity.getRelationshipItems()) { RelationshipItem relationshipItem = - mapRelationshipItem(item, trackedEntity, trackedEntity, includeDeleted); + getRelationshipItem(item, trackedEntity, trackedEntity, includeDeleted); if (relationshipItem != null) { result.add(relationshipItem); } } - trackedEntity.setRelationshipItems(result); + return result; } - private void mapRelationshipItems( + private Set getRelationshipItems( Enrollment enrollment, TrackedEntity trackedEntity, boolean includeDeleted) throws NotFoundException { Set result = new HashSet<>(); for (RelationshipItem item : enrollment.getRelationshipItems()) { - result.add(mapRelationshipItem(item, enrollment, trackedEntity, includeDeleted)); + RelationshipItem relationshipItem = + getRelationshipItem(item, enrollment, trackedEntity, includeDeleted); + if (relationshipItem != null) { + result.add(relationshipItem); + } } - enrollment.setRelationshipItems(result); + return result; } - private void mapRelationshipItems( + private Set getRelationshipItems( Event event, TrackedEntity trackedEntity, boolean includeDeleted) throws NotFoundException { Set result = new HashSet<>(); for (RelationshipItem item : event.getRelationshipItems()) { - result.add(mapRelationshipItem(item, event, trackedEntity, includeDeleted)); + RelationshipItem relationshipItem = + getRelationshipItem(item, event, trackedEntity, includeDeleted); + if (relationshipItem != null) { + result.add(relationshipItem); + } } - - event.setRelationshipItems(result); + return result; } - private RelationshipItem mapRelationshipItem( + private RelationshipItem getRelationshipItem( RelationshipItem item, BaseIdentifiableObject itemOwner, TrackedEntity trackedEntity, boolean includeDeleted) throws NotFoundException { Relationship rel = item.getRelationship(); - RelationshipItem from = withNestedEntity(trackedEntity, rel.getFrom(), includeDeleted); - RelationshipItem to = withNestedEntity(trackedEntity, rel.getTo(), includeDeleted); + + // We cannot use trackerAccessManager.canRead(getCurrentUserDetails(), rel).isEmpty() as at + // least the TE items are not hibernate proxies as they come from the aggregate store. At least + // check relationship type access. + if (!aclService.canDataRead(getCurrentUserDetails(), rel.getRelationshipType()) + || (!includeDeleted && rel.isDeleted())) { + return null; + } + + RelationshipItem from = getRelationshipItem(trackedEntity, rel.getFrom(), includeDeleted); + RelationshipItem to = getRelationshipItem(trackedEntity, rel.getTo(), includeDeleted); if (from == null || to == null) { return null; } @@ -589,6 +526,62 @@ private RelationshipItem mapRelationshipItem( return to; } + private RelationshipItem getRelationshipItem( + TrackedEntity trackedEntity, RelationshipItem item, boolean includeDeleted) + throws NotFoundException { + // relationships of relationship items are not mapped to JSON so there is no need to fetch them + RelationshipItem result = new RelationshipItem(); + + if (item.getTrackedEntity() != null) { + if (trackedEntity.getUid().equals(item.getTrackedEntity().getUid())) { + // only fetch the TE if we do not already have access to it. meaning the TE owns the item + // this is just mapping the TE + result.setTrackedEntity(trackedEntity); + } else { + result = getTrackedEntityInRelationshipItem(item.getTrackedEntity().getUid()); + } + } else if (item.getEnrollment() != null) { + result = + enrollmentService.getEnrollmentInRelationshipItem( + UID.of(item.getEnrollment()), + EnrollmentParams.TRUE.withIncludeRelationships(false), + includeDeleted); + } else if (item.getEvent() != null) { + result = + eventService.getEventInRelationshipItem( + UID.of(item.getEvent()), EventParams.TRUE.withIncludeRelationships(false)); + } + + return result; + } + + /** + * Gets a tracked entity that's part of a relationship item. This method is meant to be used when + * fetching relationship items only, because it won't throw an exception if the TE is not + * accessible. + * + * @return the TE object if found and accessible by the current user or null otherwise + * @throws NotFoundException if uid does not exist + */ + private RelationshipItem getTrackedEntityInRelationshipItem(String uid) throws NotFoundException { + RelationshipItem relationshipItem = new RelationshipItem(); + + TrackedEntity trackedEntity = trackedEntityStore.getByUid(uid); + if (trackedEntity == null) { + throw new NotFoundException(TrackedEntity.class, uid); + } + + UserDetails user = getCurrentUserDetails(); + trackedEntityAuditService.addTrackedEntityAudit(trackedEntity, user.getUsername(), READ); + + if (!trackerAccessManager.canRead(user, trackedEntity).isEmpty()) { + return null; + } + + relationshipItem.setTrackedEntity(trackedEntity); + return relationshipItem; + } + private void addSearchAudit(List trackedEntities) { if (trackedEntities.isEmpty()) { return; diff --git a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/trackedentity/TrackedEntityOperationParams.java b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/trackedentity/TrackedEntityOperationParams.java index 5d4a34da6513..3c5d23e77c57 100644 --- a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/trackedentity/TrackedEntityOperationParams.java +++ b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/trackedentity/TrackedEntityOperationParams.java @@ -105,7 +105,8 @@ public class TrackedEntityOperationParams { /** Tracked entity type to fetch. */ private UID trackedEntityType; - private OrganisationUnitSelectionMode orgUnitMode; + @Builder.Default + private OrganisationUnitSelectionMode orgUnitMode = OrganisationUnitSelectionMode.ACCESSIBLE; @Getter @Builder.Default private AssignedUserQueryParam assignedUserQueryParam = AssignedUserQueryParam.ALL; diff --git a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/trackedentity/aggregates/DefaultTrackedEntityStore.java b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/trackedentity/aggregates/DefaultTrackedEntityStore.java index e81acbea4135..8a51666f0c9f 100644 --- a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/trackedentity/aggregates/DefaultTrackedEntityStore.java +++ b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/trackedentity/aggregates/DefaultTrackedEntityStore.java @@ -158,7 +158,7 @@ public Multimap getProgramOwners(List i } @Override - public Multimap getOwnedTeis( + public Multimap getOwnedTrackedEntities( List ids, Context ctx, boolean skipUserScopeValidation) { List> teds = Lists.partition(ids, PARITITION_SIZE); diff --git a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/trackedentity/aggregates/EventAggregate.java b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/trackedentity/aggregates/EventAggregate.java index f1f3377b5d33..ac934a9dc894 100644 --- a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/trackedentity/aggregates/EventAggregate.java +++ b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/trackedentity/aggregates/EventAggregate.java @@ -105,7 +105,7 @@ Multimap findByEnrollmentIds(List ids, Context ctx) { Multimap relationships = relationshipAsync.join(); for (Event event : events.values()) { - if (ctx.getParams().isIncludeRelationships()) { + if (ctx.getParams().getEventParams().isIncludeRelationships()) { event.setRelationshipItems(new HashSet<>(relationships.get(event.getUid()))); } diff --git a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/trackedentity/aggregates/TrackedEntityAggregate.java b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/trackedentity/aggregates/TrackedEntityAggregate.java index a16f54a896c0..3053b96e0445 100644 --- a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/trackedentity/aggregates/TrackedEntityAggregate.java +++ b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/trackedentity/aggregates/TrackedEntityAggregate.java @@ -208,13 +208,13 @@ public List find( supplyAsync(() -> trackedEntityStore.getAttributes(ids), getPool()); /* - * Async fetch Owned Tei mapped to the provided program attributes by + * Async fetch Owned TE mapped to the provided program attributes by * TrackedEntity id */ final CompletableFuture> ownedTeiAsync = conditionalAsyncFetch( user.isPresent(), - () -> trackedEntityStore.getOwnedTeis(ids, ctx, orgUnitMode == ALL), + () -> trackedEntityStore.getOwnedTrackedEntities(ids, ctx, orgUnitMode == ALL), getPool()); /* * Execute all queries and merge the results diff --git a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/trackedentity/aggregates/TrackedEntityStore.java b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/trackedentity/aggregates/TrackedEntityStore.java index ace3f14843b6..027b335e7f34 100644 --- a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/trackedentity/aggregates/TrackedEntityStore.java +++ b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/trackedentity/aggregates/TrackedEntityStore.java @@ -79,6 +79,6 @@ public interface TrackedEntityStore { * @param ctx aggregate context * @return Tei uids mapped to a list of program uids to which user has ownership */ - Multimap getOwnedTeis( + Multimap getOwnedTrackedEntities( List ids, Context ctx, boolean skipUserScopeValidation); } diff --git a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/trackedentity/aggregates/mapper/TrackedEntityRowCallbackHandler.java b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/trackedentity/aggregates/mapper/TrackedEntityRowCallbackHandler.java index 559cb83ca338..59c4131fbc59 100644 --- a/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/trackedentity/aggregates/mapper/TrackedEntityRowCallbackHandler.java +++ b/dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/export/trackedentity/aggregates/mapper/TrackedEntityRowCallbackHandler.java @@ -50,8 +50,7 @@ public TrackedEntityRowCallbackHandler() { this.items = new LinkedHashMap<>(); } - private TrackedEntity getTei(ResultSet rs) throws SQLException { - + private TrackedEntity getTrackedEntity(ResultSet rs) throws SQLException { TrackedEntity te = new TrackedEntity(); te.setUid(rs.getString(TrackedEntityQuery.getColumnName(COLUMNS.UID))); TrackedEntityType trackedEntityType = new TrackedEntityType(); @@ -85,7 +84,7 @@ private TrackedEntity getTei(ResultSet rs) throws SQLException { @Override public void processRow(ResultSet rs) throws SQLException { - this.items.put(rs.getString("te_uid"), getTei(rs)); + this.items.put(rs.getString("te_uid"), getTrackedEntity(rs)); } public Map getItems() { diff --git a/dhis-2/dhis-services/dhis-service-validation/src/main/java/org/hisp/dhis/minmax/hibernate/HibernateMinMaxDataElementStore.java b/dhis-2/dhis-services/dhis-service-validation/src/main/java/org/hisp/dhis/minmax/hibernate/HibernateMinMaxDataElementStore.java index f7c977b97f07..1a33cfcc4a2f 100644 --- a/dhis-2/dhis-services/dhis-service-validation/src/main/java/org/hisp/dhis/minmax/hibernate/HibernateMinMaxDataElementStore.java +++ b/dhis-2/dhis-services/dhis-service-validation/src/main/java/org/hisp/dhis/minmax/hibernate/HibernateMinMaxDataElementStore.java @@ -37,8 +37,10 @@ import java.util.ArrayList; import java.util.Collection; import java.util.List; +import javax.annotation.Nonnull; import org.hisp.dhis.category.CategoryOptionCombo; import org.hisp.dhis.common.Pager; +import org.hisp.dhis.common.UID; import org.hisp.dhis.dataelement.DataElement; import org.hisp.dhis.hibernate.HibernateGenericStore; import org.hisp.dhis.hibernate.JpaQueryParameters; @@ -193,6 +195,19 @@ public List getByDataElement(Collection dataElem .list(); } + @Override + public List getByCategoryOptionCombo(@Nonnull Collection uids) { + if (uids.isEmpty()) return List.of(); + return getQuery( + """ + select distinct mmde from MinMaxDataElement mmde + join mmde.optionCombo coc + where coc.uid in :uids + """) + .setParameter("uids", UID.toValueList(uids)) + .list(); + } + private Predicate parseFilter(CriteriaBuilder builder, Root root, List filters) { Predicate conjunction = builder.conjunction(); diff --git a/dhis-2/dhis-support/dhis-support-external/src/main/java/org/hisp/dhis/external/config/ServiceConfig.java b/dhis-2/dhis-support/dhis-support-external/src/main/java/org/hisp/dhis/external/config/ServiceConfig.java index 9fcfaeedbcbf..fa358b1e7288 100644 --- a/dhis-2/dhis-support/dhis-support-external/src/main/java/org/hisp/dhis/external/config/ServiceConfig.java +++ b/dhis-2/dhis-support/dhis-support-external/src/main/java/org/hisp/dhis/external/config/ServiceConfig.java @@ -29,7 +29,6 @@ import static org.hisp.dhis.external.conf.ConfigurationKey.META_DATA_SYNC_RETRY; import static org.hisp.dhis.external.conf.ConfigurationKey.META_DATA_SYNC_RETRY_TIME_FREQUENCY_MILLISEC; -import static org.hisp.dhis.external.conf.ConfigurationKey.SYSTEM_SESSION_TIMEOUT; import org.hisp.dhis.external.conf.ConfigurationPropertyFactoryBean; import org.hisp.dhis.external.location.DefaultLocationManager; @@ -60,9 +59,4 @@ public ConfigurationPropertyFactoryBean maxAttempts() { public ConfigurationPropertyFactoryBean initialInterval() { return new ConfigurationPropertyFactoryBean(META_DATA_SYNC_RETRY_TIME_FREQUENCY_MILLISEC); } - - @Bean - public ConfigurationPropertyFactoryBean sessionTimeout() { - return new ConfigurationPropertyFactoryBean(SYSTEM_SESSION_TIMEOUT); - } } diff --git a/dhis-2/dhis-support/dhis-support-test/src/main/java/org/hisp/dhis/test/TestBase.java b/dhis-2/dhis-support/dhis-support-test/src/main/java/org/hisp/dhis/test/TestBase.java index 353ee96c8e46..f5e549b9218a 100644 --- a/dhis-2/dhis-support/dhis-support-test/src/main/java/org/hisp/dhis/test/TestBase.java +++ b/dhis-2/dhis-support/dhis-support-test/src/main/java/org/hisp/dhis/test/TestBase.java @@ -78,6 +78,7 @@ import org.hisp.dhis.category.CategoryOptionGroup; import org.hisp.dhis.category.CategoryOptionGroupSet; import org.hisp.dhis.category.CategoryService; +import org.hisp.dhis.common.BaseIdentifiableObject; import org.hisp.dhis.common.CodeGenerator; import org.hisp.dhis.common.DataDimensionType; import org.hisp.dhis.common.DeliveryChannel; @@ -185,6 +186,7 @@ import org.hisp.dhis.setting.SessionUserSettings; import org.hisp.dhis.sqlview.SqlView; import org.hisp.dhis.sqlview.SqlViewType; +import org.hisp.dhis.test.api.TestCategoryMetadata; import org.hisp.dhis.test.utils.Dxf2NamespaceResolver; import org.hisp.dhis.test.utils.RelationshipUtils; import org.hisp.dhis.trackedentity.TrackedEntity; @@ -3039,4 +3041,68 @@ public static User createRandomAdminUserWithEntityManager(EntityManager entityMa return user; } + + protected TestCategoryMetadata setupCategoryMetadata() { + // 8 category options + CategoryOption co1A = createCategoryOption("1A", CodeGenerator.generateUid()); + CategoryOption co1B = createCategoryOption("1B", CodeGenerator.generateUid()); + CategoryOption co2A = createCategoryOption("2A", CodeGenerator.generateUid()); + CategoryOption co2B = createCategoryOption("2B", CodeGenerator.generateUid()); + CategoryOption co3A = createCategoryOption("3A", CodeGenerator.generateUid()); + CategoryOption co3B = createCategoryOption("3B", CodeGenerator.generateUid()); + CategoryOption co4A = createCategoryOption("4A", CodeGenerator.generateUid()); + CategoryOption co4B = createCategoryOption("4B", CodeGenerator.generateUid()); + categoryService.addCategoryOption(co1A); + categoryService.addCategoryOption(co1B); + categoryService.addCategoryOption(co2A); + categoryService.addCategoryOption(co2B); + categoryService.addCategoryOption(co3A); + categoryService.addCategoryOption(co3B); + categoryService.addCategoryOption(co4A); + categoryService.addCategoryOption(co4B); + + // 4 categories (each with 2 category options) + Category cat1 = createCategory('1', co1A, co1B); + Category cat2 = createCategory('2', co2A, co2B); + Category cat3 = createCategory('3', co3A, co3B); + Category cat4 = createCategory('4', co4A, co4B); + categoryService.addCategory(cat1); + categoryService.addCategory(cat2); + categoryService.addCategory(cat3); + categoryService.addCategory(cat4); + + CategoryCombo cc1 = createCategoryCombo('1', cat1, cat2); + CategoryCombo cc2 = createCategoryCombo('2', cat3, cat4); + categoryService.addCategoryCombo(cc1); + categoryService.addCategoryCombo(cc2); + + categoryService.generateOptionCombos(cc1); + categoryService.generateOptionCombos(cc2); + + CategoryOptionCombo coc1A2A = getCocWithOptions("1A", "2A"); + CategoryOptionCombo coc1B2B = getCocWithOptions("1A", "2B"); + CategoryOptionCombo coc3A4A = getCocWithOptions("3A", "4A"); + CategoryOptionCombo coc3A4B = getCocWithOptions("3A", "4B"); + + return new TestCategoryMetadata( + cc1, cc2, cat1, cat2, cat3, cat4, co1A, co1B, co2A, co2B, co3A, co3B, co4A, co4B, coc1A2A, + coc1B2B, coc3A4A, coc3A4B); + } + + private CategoryOptionCombo getCocWithOptions(String co1, String co2) { + List allCategoryOptionCombos = + categoryService.getAllCategoryOptionCombos(); + + return allCategoryOptionCombos.stream() + .filter( + coc -> { + Set categoryOptions = + coc.getCategoryOptions().stream() + .map(BaseIdentifiableObject::getName) + .collect(Collectors.toSet()); + return categoryOptions.containsAll(List.of(co1, co2)); + }) + .toList() + .get(0); + } } diff --git a/dhis-2/dhis-support/dhis-support-test/src/main/java/org/hisp/dhis/test/api/TestCategoryMetadata.java b/dhis-2/dhis-support/dhis-support-test/src/main/java/org/hisp/dhis/test/api/TestCategoryMetadata.java new file mode 100644 index 000000000000..c2ac011e2030 --- /dev/null +++ b/dhis-2/dhis-support/dhis-support-test/src/main/java/org/hisp/dhis/test/api/TestCategoryMetadata.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2004-2025, University of Oslo + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 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. + * Neither the name of the HISP project 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.test.api; + +import org.hisp.dhis.category.Category; +import org.hisp.dhis.category.CategoryCombo; +import org.hisp.dhis.category.CategoryOption; +import org.hisp.dhis.category.CategoryOptionCombo; + +public record TestCategoryMetadata( + CategoryCombo cc1, + CategoryCombo cc2, + Category c1, + Category c2, + Category c3, + Category c4, + CategoryOption co1, + CategoryOption co2, + CategoryOption co3, + CategoryOption co4, + CategoryOption co5, + CategoryOption co6, + CategoryOption co7, + CategoryOption co8, + CategoryOptionCombo coc1, + CategoryOptionCombo coc2, + CategoryOptionCombo coc3, + CategoryOptionCombo coc4) {} diff --git a/dhis-2/dhis-support/dhis-support-test/src/main/java/org/hisp/dhis/test/utils/Assertions.java b/dhis-2/dhis-support/dhis-support-test/src/main/java/org/hisp/dhis/test/utils/Assertions.java index b072a24aa164..763e989f1a05 100644 --- a/dhis-2/dhis-support/dhis-support-test/src/main/java/org/hisp/dhis/test/utils/Assertions.java +++ b/dhis-2/dhis-support/dhis-support-test/src/main/java/org/hisp/dhis/test/utils/Assertions.java @@ -191,24 +191,59 @@ public static void assertNotEmpty(Collection actual, String message) { assertFalse(actual.isEmpty(), message); } + /** + * Asserts that the given collection is not null and not empty. + * + * @param actual the collection. + * @param messageSupplier fails with this supplied message + */ + public static void assertNotEmpty(Collection actual, Supplier messageSupplier) { + assertNotNull(actual, messageSupplier); + assertFalse(actual.isEmpty(), messageSupplier); + } + /** * Asserts that the given collection contains the expected number of elements. * * @param actual the collection. */ public static void assertHasSize(int expected, Collection actual) { - assert expected > 0 : "use assertIsEmpty"; - - assertNotEmpty(actual); - assertEquals( + assertHasSize( expected, - actual.size(), + actual, () -> String.format( "expected collection to contain %d elements, it has %d instead: '%s'", expected, actual.size(), actual)); } + /** + * Asserts that the given collection contains the expected number of elements. + * + * @param actual the collection. + * @param messageSupplier fails with this supplied message + */ + public static void assertHasSize( + int expected, Collection actual, Supplier messageSupplier) { + assert expected > 0 : "use assertIsEmpty"; + + assertNotEmpty(actual); + assertEquals(expected, actual.size(), messageSupplier); + } + + /** + * Asserts that the given collection contains the expected number of elements. + * + * @param actual the collection. + * @param message fails with this message + */ + public static void assertHasSize(int expected, Collection actual, String message) { + assert expected > 0 : "use assertIsEmpty"; + + assertNotEmpty(actual); + assertEquals(expected, actual.size(), message); + } + /** * Asserts that the given string starts with the expected prefix. * diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/DhisHttpSessionEventListener.java b/dhis-2/dhis-support/dhis-support-test/src/main/java/org/hisp/dhis/test/webapi/json/domain/JsonUserRole.java similarity index 55% rename from dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/DhisHttpSessionEventListener.java rename to dhis-2/dhis-support/dhis-support-test/src/main/java/org/hisp/dhis/test/webapi/json/domain/JsonUserRole.java index 03b9851effc1..d468c6623a79 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/security/DhisHttpSessionEventListener.java +++ b/dhis-2/dhis-support/dhis-support-test/src/main/java/org/hisp/dhis/test/webapi/json/domain/JsonUserRole.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2004-2023, University of Oslo + * Copyright (c) 2004-2025, University of Oslo * All rights reserved. * * Redistribution and use in source and binary forms, with or without @@ -25,36 +25,17 @@ * (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.security; +package org.hisp.dhis.test.webapi.json.domain; -import jakarta.servlet.http.HttpSession; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.hisp.dhis.external.conf.ConfigurationKey; -import org.hisp.dhis.external.conf.DhisConfigurationProvider; -import org.springframework.context.event.EventListener; -import org.springframework.security.web.session.HttpSessionCreatedEvent; -import org.springframework.stereotype.Component; +import org.hisp.dhis.jsontree.JsonList; +import org.hisp.dhis.jsontree.JsonString; -/** - * @author Morten Svanæs - */ -@Component -@Slf4j -@RequiredArgsConstructor -public class DhisHttpSessionEventListener { - private final DhisConfigurationProvider config; +public interface JsonUserRole extends JsonIdentifiableObject { + default JsonList getUsers() { + return getList("users", JsonUser.class); + } - @EventListener - public void sessionCreated(HttpSessionCreatedEvent event) { - HttpSession session = event.getSession(); - try { - String property = config.getProperty(ConfigurationKey.SYSTEM_SESSION_TIMEOUT); - session.setMaxInactiveInterval(Integer.parseInt(property)); - } catch (Exception e) { - session.setMaxInactiveInterval( - Integer.parseInt(ConfigurationKey.SYSTEM_SESSION_TIMEOUT.getDefaultValue())); - log.error("Could not read session timeout value from config", e); - } + default JsonList getAuthorities() { + return getList("authorities", JsonString.class); } } diff --git a/dhis-2/dhis-support/dhis-support-test/src/main/resources/tracker/simple_metadata.json b/dhis-2/dhis-support/dhis-support-test/src/main/resources/tracker/simple_metadata.json index 2b61abbb750d..24a0f27d194f 100644 --- a/dhis-2/dhis-support/dhis-support-test/src/main/resources/tracker/simple_metadata.json +++ b/dhis-2/dhis-support/dhis-support-test/src/main/resources/tracker/simple_metadata.json @@ -1070,7 +1070,7 @@ "attribute": { "id": "j45AR9cBQKc" }, - "value": "programStage qLZC0lvvxQH" + "value": "multi-program-stage-attribute" } ], "autoGenerateEvent": true, @@ -1126,15 +1126,7 @@ } ], "validationStrategy": "ON_UPDATE_AND_INSERT", - "featureType": "POINT", - "attributeValues": [ - { - "attribute": { - "id": "j45AR9cBQKc" - }, - "value": "multi-program-stage-attribute" - } - ] + "featureType": "POINT" }, { "id": "SKNvpoLioON", @@ -1851,6 +1843,29 @@ }, "relationshipEntity": "PROGRAM_STAGE_INSTANCE" } + }, + { + "id": "m1575931405", + "displayFromToName": "Parent Of", + "displayName": "Parent to Child", + "fromConstraint": { + "relationshipEntity": "TRACKED_ENTITY_INSTANCE", + "trackedEntityType": { + "id": "ja8NY4PW7Xm" + } + }, + "fromToName": "Child Of", + "name": "Tracked entity to tracked entity", + "sharing": { + "owner": null, + "public": "r-------" + }, + "toConstraint": { + "relationshipEntity": "TRACKED_ENTITY_INSTANCE", + "trackedEntityType": { + "id": "ja8NY4PW7Xm" + } + } } ], "trackedEntityAttributes": [ @@ -2230,6 +2245,41 @@ ], "username": "trackeradmin" }, + { + "id": "Z7870757a75", + "access": { + "delete": true, + "externalize": true, + "manage": true, + "read": true, + "update": true, + "write": true + }, + "dataViewOrganisationUnits": [ + { + "id": "h4w96yEMlzO" + } + ], + "displayName": "basicuser", + "email": "basic@dhis2.org", + "firstName": "basic", + "name": "user", + "organisationUnits": [ + { + "id": "h4w96yEMlzO" + } + ], + "password": "Test123###...", + "passwordLastUpdated": "2020-05-31T08:57:59.060", + "phoneNumber": "+4740332255", + "surname": "basic", + "userRoles": [ + { + "id": "UbhT3bXWUyb" + } + ], + "username": "basicuser" + }, { "id": "o1HMTIzBGo7", "access": { diff --git a/dhis-2/dhis-test-e2e/pom.xml b/dhis-2/dhis-test-e2e/pom.xml index 0b952e1fe3f4..d9f11bee9c91 100644 --- a/dhis-2/dhis-test-e2e/pom.xml +++ b/dhis-2/dhis-test-e2e/pom.xml @@ -9,7 +9,7 @@ .. UTF-8 - 2.44.1 + 2.44.2 3.13.0 3.5.2 1.4.0 diff --git a/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/helpers/extensions/MetadataSetupExtension.java b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/helpers/extensions/MetadataSetupExtension.java index 07079ff7b6d7..a87aa36ee979 100644 --- a/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/helpers/extensions/MetadataSetupExtension.java +++ b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/helpers/extensions/MetadataSetupExtension.java @@ -39,8 +39,10 @@ import org.hisp.dhis.test.e2e.Constants; import org.hisp.dhis.test.e2e.TestRunStorage; import org.hisp.dhis.test.e2e.actions.LoginActions; +import org.hisp.dhis.test.e2e.actions.MaintenanceActions; import org.hisp.dhis.test.e2e.actions.UserActions; import org.hisp.dhis.test.e2e.actions.metadata.MetadataActions; +import org.hisp.dhis.test.e2e.helpers.QueryParamsBuilder; import org.hisp.dhis.test.e2e.helpers.config.TestConfiguration; import org.junit.jupiter.api.extension.BeforeAllCallback; import org.junit.jupiter.api.extension.ExtensionContext; @@ -55,6 +57,7 @@ public class MetadataSetupExtension private static final Map createdData = new LinkedHashMap<>(); private static final Logger logger = LogManager.getLogger(MetadataSetupExtension.class.getName()); + private static final MaintenanceActions maintenanceApiActions = new MaintenanceActions(); @Override public void beforeAll(ExtensionContext context) { @@ -130,10 +133,11 @@ public void close() { if (TestConfiguration.get().shouldCleanUp()) { TestCleanUp testCleanUp = new TestCleanUp(); - iterateCreatedData( - id -> { - testCleanUp.deleteEntity(createdData.get(id), id); - }); + iterateCreatedData(id -> testCleanUp.deleteEntity(createdData.get(id), id)); + // clean-up category option combos, which are autogenerated (not tracked by e2e tests) + maintenanceApiActions + .post("categoryOptionComboUpdate", new QueryParamsBuilder().build()) + .validateStatus(204); } } } diff --git a/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/merge/CategoryOptionComboMergeTest.java b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/merge/CategoryOptionComboMergeTest.java new file mode 100644 index 000000000000..5d390af481b7 --- /dev/null +++ b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/merge/CategoryOptionComboMergeTest.java @@ -0,0 +1,1348 @@ +/* + * Copyright (c) 2004-2024, 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: + * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 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. + * Neither the name of the HISP project 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.merge; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasEntry; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.hasItems; +import static org.hamcrest.Matchers.hasSize; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import io.restassured.response.ValidatableResponse; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import lombok.extern.slf4j.Slf4j; +import org.awaitility.Awaitility; +import org.hisp.dhis.ApiTest; +import org.hisp.dhis.test.e2e.actions.LoginActions; +import org.hisp.dhis.test.e2e.actions.RestApiActions; +import org.hisp.dhis.test.e2e.actions.UserActions; +import org.hisp.dhis.test.e2e.actions.metadata.MetadataActions; +import org.hisp.dhis.test.e2e.dto.ApiResponse; +import org.hisp.dhis.test.e2e.helpers.JsonObjectBuilder; +import org.hisp.dhis.test.e2e.helpers.JsonParserUtils; +import org.hisp.dhis.test.e2e.helpers.QueryParamsBuilder; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@Slf4j +class CategoryOptionComboMergeTest extends ApiTest { + + private RestApiActions categoryOptionComboApiActions; + private RestApiActions dataElementApiActions; + private RestApiActions minMaxActions; + private MetadataActions metadataActions; + private RestApiActions visualizationActions; + private RestApiActions maintenanceApiActions; + private RestApiActions dataValueSetActions; + private UserActions userActions; + private LoginActions loginActions; + private String sourceUid1; + private String sourceUid2; + private String targetUid; + private String randomCocUid1; + private String randomCocUid2; + private String mergeUserId; + + @BeforeAll + public void before() { + userActions = new UserActions(); + loginActions = new LoginActions(); + dataElementApiActions = new RestApiActions("dataElements"); + minMaxActions = new RestApiActions("minMaxDataElements"); + categoryOptionComboApiActions = new RestApiActions("categoryOptionCombos"); + metadataActions = new MetadataActions(); + maintenanceApiActions = new RestApiActions("maintenance"); + dataValueSetActions = new RestApiActions("dataValueSets"); + visualizationActions = new RestApiActions("visualizations"); + loginActions.loginAsSuperUser(); + + // add user with required merge auth + mergeUserId = + userActions.addUserFull( + "user", "auth", "userWithMergeAuth", "Test1234!", "F_CATEGORY_OPTION_COMBO_MERGE"); + } + + @BeforeEach + public void setup() { + loginActions.loginAsSuperUser(); + setupMetadata(); + } + + @AfterAll + public void resetSuperUserOrgUnit() { + loginActions.loginAsSuperUser(); + // reset super user to have same org unit access as setup data + addOrgUnitAccessForUser( + loginActions.getLoggedInUserId(), + "ImspTQPwCqd", + "O6uvpzGd5pu", + "g8upMTyEZGZ", + "YuQRtpLP10I"); + } + + @Test + @DisplayName( + "Valid CategoryOptionCombo merge completes successfully with all source CategoryOptionCombo refs replaced with target CategoryOptionCombo") + void validCategoryOptionComboMergeTest() { + // given + // generate category option combos + maintenanceApiActions + .post("categoryOptionComboUpdate", new QueryParamsBuilder().build()) + .validateStatus(204); + + // get cat opt combo uids for sources and target, after generating + sourceUid1 = getCocWithOptions("1A", "2A"); + sourceUid2 = getCocWithOptions("1B", "2B"); + targetUid = getCocWithOptions("3A", "4B"); + + // confirm state before merge + ValidatableResponse preMergeState = + categoryOptionComboApiActions.get(targetUid).validateStatus(200).validate(); + + preMergeState + .body("categoryCombo", hasEntry("id", "CatComUid02")) + .body("categoryOptions", hasSize(equalTo(2))) + .body("categoryOptions", hasItem(hasEntry("id", "CatOptUid4B"))) + .body("categoryOptions", hasItem(hasEntry("id", "CatOptUid3A"))); + + String dataElement = setupDataElement("test de 1"); + // import visualization to persist data dimension item which has ref to source coc + visualizationActions.post(getViz(dataElement, sourceUid1)).validateStatus(201); + + // login as merge user + loginActions.loginAsUser("userWithMergeAuth", "Test1234!"); + + // when a category option combo request is submitted, deleting sources + ApiResponse response = + categoryOptionComboApiActions.post("merge", getMergeBody("DISCARD")).validateStatus(200); + + // then a success response received, sources are deleted & source references were merged + response + .validate() + .statusCode(200) + .body("httpStatus", equalTo("OK")) + .body("response.mergeReport.message", equalTo("CategoryOptionCombo merge complete")) + .body("response.mergeReport.mergeErrors", empty()) + .body("response.mergeReport.mergeType", equalTo("CategoryOptionCombo")) + .body("response.mergeReport.sourcesDeleted", hasItems(sourceUid1, sourceUid2)); + + categoryOptionComboApiActions.get(sourceUid1).validateStatus(404); + categoryOptionComboApiActions.get(sourceUid2).validateStatus(404); + ValidatableResponse postMergeState = + categoryOptionComboApiActions.get(targetUid).validateStatus(200).validate(); + + postMergeState + .body("categoryCombo", hasEntry("id", "CatComUid02")) + .body("categoryOptions", hasSize(equalTo(6))) + .body( + "categoryOptions", + hasItems( + hasEntry("id", "CatOptUid1A"), + hasEntry("id", "CatOptUid2B"), + hasEntry("id", "CatOptUid3A"), + hasEntry("id", "CatOptUid4B"), + hasEntry("id", "CatOptUid2A"), + hasEntry("id", "CatOptUid1B"))); + } + + @Test + @DisplayName( + "CategoryOptionCombo merge completes successfully with DataValues (cat opt combo) handled correctly") + void cocMergeDataValuesTest() { + // Given + // Generate category option combos + maintenanceApiActions + .post("categoryOptionComboUpdate", new QueryParamsBuilder().build()) + .validateStatus(204); + + // Get cat opt combo uids for sources and target, after generating + sourceUid1 = getCocWithOptions("1A", "2A"); + sourceUid2 = getCocWithOptions("1A", "2B"); + targetUid = getCocWithOptions("3A", "4A"); + randomCocUid1 = getCocWithOptions("1B", "2A"); + randomCocUid2 = getCocWithOptions("1B", "2B"); + + addOrgUnitAccessForUser(loginActions.getLoggedInUserId(), "OrgUnitUid1"); + addOrgUnitAccessForUser(mergeUserId, "OrgUnitUid1"); + + // Add data values + addDataValuesCoc(); + + // Wait 2 seconds, so that lastUpdated values have a different value, + // which is crucial for choosing which data values to keep/delete + Awaitility.await().pollDelay(2, TimeUnit.SECONDS).until(() -> true); + + // Update some data values, ensures different 'lastUpdated' values for duplicate logic + updateDataValuesCoc(); + + // Confirm Data Value state before merge + ValidatableResponse preMergeState = + dataValueSetActions + .get(getDataValueSetQueryParams("OrgUnitUid1")) + .validateStatus(200) + .validate(); + + preMergeState.body("dataValues", hasSize(14)); + Set uniqueDates = + new HashSet<>(preMergeState.extract().jsonPath().getList("dataValues.lastUpdated")); + assertTrue(uniqueDates.size() > 1, "There should be more than 1 unique date present"); + + // Login as merge user + loginActions.loginAsUser("userWithMergeAuth", "Test1234!"); + + // When a merge request using the data merge strategy 'LAST_UPDATED' is submitted + ApiResponse response = + categoryOptionComboApiActions + .post("merge", getMergeBody("LAST_UPDATED")) + .validateStatus(200); + + // Then a success response received, sources are deleted & source references were merged + response + .validate() + .statusCode(200) + .body("httpStatus", equalTo("OK")) + .body("response.mergeReport.message", equalTo("CategoryOptionCombo merge complete")) + .body("response.mergeReport.mergeErrors", empty()) + .body("response.mergeReport.mergeType", equalTo("CategoryOptionCombo")) + .body("response.mergeReport.sourcesDeleted", hasItems(sourceUid1, sourceUid2)); + + // And sources should no longer exist + categoryOptionComboApiActions.get(sourceUid1).validateStatus(404); + categoryOptionComboApiActions.get(sourceUid2).validateStatus(404); + + // And last updated duplicates are kept and earlier duplicates deleted + ValidatableResponse postMergeState = + dataValueSetActions + .get(getDataValueSetQueryParams("OrgUnitUid1")) + .validateStatus(200) + .validate(); + + postMergeState.body("dataValues", hasSize(8)); + + // Check for expected values + List datValues = postMergeState.extract().jsonPath().getList("dataValues.value"); + assertTrue(datValues.contains("UPDATED source 1 DV 3 - duplicate later - KEEP")); + assertTrue(datValues.contains("UPDATED source 2 DV 2 - duplicate later - KEEP")); + assertTrue(datValues.contains("UPDATED target DV 4 - duplicate later - KEEP")); + + assertFalse(datValues.contains("source 1, DV 2 - duplicate earlier - REMOVE")); + assertFalse(datValues.contains("source 1, DV 4 - duplicate earlier - REMOVE")); + assertFalse(datValues.contains("source 2, DV 3 - duplicate earlier - REMOVE")); + assertFalse(datValues.contains("source 2, DV 4 - duplicate earlier - REMOVE")); + assertFalse(datValues.contains("target DV 1 - duplicate earlier - REMOVE")); + assertFalse(datValues.contains("target DV 2 - duplicate earlier - REMOVE")); + + Set dvCocs = + new HashSet<>( + postMergeState.extract().jsonPath().getList("dataValues.categoryOptionCombo")); + assertTrue(dvCocs.contains(targetUid), "Target COC is present"); + assertFalse(dvCocs.contains(sourceUid1), "Source COC 1 should not be present"); + assertFalse(dvCocs.contains(sourceUid2), "Source COC 2 should not be present"); + } + + @Test + @DisplayName( + "CategoryOptionCombo merge completes successfully with DataValues (attr opt combo) handled correctly") + void aocMergeDataValuesTest() { + // Given + // Generate category option combos + maintenanceApiActions + .post("categoryOptionComboUpdate", new QueryParamsBuilder().build()) + .validateStatus(204); + + // Get cat opt combo uids for sources and target, after generating + sourceUid1 = getCocWithOptions("1A", "2A"); + sourceUid2 = getCocWithOptions("1A", "2B"); + targetUid = getCocWithOptions("3A", "4A"); + randomCocUid1 = getCocWithOptions("1B", "2A"); + randomCocUid2 = getCocWithOptions("1B", "2B"); + + addOrgUnitAccessForUser(loginActions.getLoggedInUserId(), "OrgUnitUid2"); + addOrgUnitAccessForUser(mergeUserId, "OrgUnitUid2"); + + // Add data values + addDataValuesAoc(); + + // Wait 2 seconds, so that lastUpdated values have a different value, + // which is crucial for choosing which data values to keep/delete + Awaitility.await().pollDelay(2, TimeUnit.SECONDS).until(() -> true); + + // Update some data values, ensures different 'lastUpdated' values for duplicate logic + updateDataValuesAoc(); + + // Confirm Data Value state before merge + ValidatableResponse preMergeState = + dataValueSetActions + .get(getDataValueSetQueryParams("OrgUnitUid2")) + .validateStatus(200) + .validate(); + + preMergeState.body("dataValues", hasSize(14)); + Set uniqueDates = + new HashSet<>(preMergeState.extract().jsonPath().getList("dataValues.lastUpdated")); + assertTrue(uniqueDates.size() > 1, "There should be more than 1 unique date present"); + + // Login as merge user + loginActions.loginAsUser("userWithMergeAuth", "Test1234!"); + + // When a merge request using the data merge strategy 'LAST_UPDATED' is submitted + ApiResponse response = + categoryOptionComboApiActions + .post("merge", getMergeBody("LAST_UPDATED")) + .validateStatus(200); + + // Then a success response received, sources are deleted & source references were merged + response + .validate() + .statusCode(200) + .body("httpStatus", equalTo("OK")) + .body("response.mergeReport.message", equalTo("CategoryOptionCombo merge complete")) + .body("response.mergeReport.mergeErrors", empty()) + .body("response.mergeReport.mergeType", equalTo("CategoryOptionCombo")) + .body("response.mergeReport.sourcesDeleted", hasItems(sourceUid1, sourceUid2)); + + // And sources should no longer exist + categoryOptionComboApiActions.get(sourceUid1).validateStatus(404); + categoryOptionComboApiActions.get(sourceUid2).validateStatus(404); + + // And last updated duplicates are kept and earlier duplicates deleted + loginActions.loginAsSuperUser(); + ValidatableResponse postMergeState = + dataValueSetActions + .get(getDataValueSetQueryParamsWithAoc("OrgUnitUid2")) + .validateStatus(200) + .validate(); + + postMergeState.body("dataValues", hasSize(6)); + + // Check for expected values + List datValues = postMergeState.extract().jsonPath().getList("dataValues.value"); + assertTrue(datValues.contains("UPDATED source 1 DV 3 - duplicate later - KEEP")); + assertTrue(datValues.contains("UPDATED source 2 DV 2 - duplicate later - KEEP")); + assertTrue(datValues.contains("UPDATED target DV 4 - duplicate later - KEEP")); + + assertFalse(datValues.contains("source 1, DV 2 - duplicate earlier - REMOVE")); + assertFalse(datValues.contains("source 1, DV 4 - duplicate earlier - REMOVE")); + assertFalse(datValues.contains("source 2, DV 3 - duplicate earlier - REMOVE")); + assertFalse(datValues.contains("source 2, DV 4 - duplicate earlier - REMOVE")); + assertFalse(datValues.contains("target DV 1 - duplicate earlier - REMOVE")); + assertFalse(datValues.contains("target DV 2 - duplicate earlier - REMOVE")); + + Set dvAocs = + new HashSet<>( + postMergeState.extract().jsonPath().getList("dataValues.attributeOptionCombo")); + assertTrue(dvAocs.contains(targetUid), "Target COC is present"); + assertFalse(dvAocs.contains(sourceUid1), "Source COC 1 should not be present"); + assertFalse(dvAocs.contains(sourceUid2), "Source COC 2 should not be present"); + } + + private void addDataValuesCoc() { + dataValueSetActions + .post( + dataValueSetImportCoc(sourceUid1, sourceUid2, targetUid, randomCocUid1, randomCocUid2), + getDataValueQueryParams()) + .validateStatus(200) + .validate() + .body("response.importCount.imported", equalTo(14)); + } + + private void addDataValuesAoc() { + dataValueSetActions + .post( + dataValueSetImportAoc(sourceUid1, sourceUid2, targetUid, randomCocUid1, randomCocUid2), + getDataValueQueryParams()) + .validateStatus(200); + } + + private void updateDataValuesCoc() { + dataValueSetActions + .post( + dataValueSetImportUpdateCoc(sourceUid1, sourceUid2, targetUid), + getDataValueQueryParams()) + .validateStatus(200) + .validate() + .body("response.importCount.updated", equalTo(4)); + } + + private void updateDataValuesAoc() { + dataValueSetActions + .post( + dataValueSetImportUpdateAoc(sourceUid1, sourceUid2, targetUid), + getDataValueQueryParams()) + .validateStatus(200) + .validate() + .body("response.importCount.updated", equalTo(4)); + } + + private String getViz(String dataElement, String coc) { + return """ + + { + "name": "Test viz with data dimension item - DE operand", + "displayName": "Test 1", + "type": "PIVOT_TABLE", + "filters": [ + { + "dimension": "ou", + "items": [ + { + "id": "USER_ORGUNIT" + } + ] + } + ], + "columns": [ + { + "dimension": "dx", + "items": [ + { + "id": "%s.%s", + "dimensionItemType": "DATA_ELEMENT_OPERAND" + } + ] + } + ], + "rows": [ + { + "dimension": "pe", + "items": [ + { + "id": "LAST_10_YEARS" + } + ] + } + ] + } + """ + .formatted(dataElement, coc); + } + + private QueryParamsBuilder getDataValueQueryParams() { + return new QueryParamsBuilder() + .add("async=false") + .add("dryRun=false") + .add("strategy=NEW_AND_UPDATES") + .add("preheatCache=false") + .add("dataElementIdScheme=UID") + .add("orgUnitIdScheme=UID") + .add("idScheme=UID") + .add("format=json") + .add("skipExistingCheck=false"); + } + + private String getDataValueSetQueryParams(String orgUnit) { + return new QueryParamsBuilder() + .add("orgUnit=%s") + .add("startDate=2024-01-01") + .add("endDate=2050-01-30") + .add("dataElement=deUid000001") + .build() + .formatted(orgUnit); + } + + private String getDataValueSetQueryParamsWithAoc(String orgUnit) { + return new QueryParamsBuilder() + .add("orgUnit=%s") + .add("startDate=2024-01-01") + .add("endDate=2050-01-30") + .add("dataElement=deUid000001") + .add("attributeOptionCombo=" + targetUid) + .build() + .formatted(orgUnit); + } + + private void addOrgUnitAccessForUser(String loggedInUserId, String... orgUnitUids) { + JsonArray orgUnits = new JsonArray(); + for (String orgUnit : orgUnitUids) { + orgUnits.add(JsonObjectBuilder.jsonObject().addProperty("id", orgUnit).build()); + } + JsonObject userPatch = + JsonObjectBuilder.jsonObject() + .addProperty("op", "add") + .addProperty("path", "/organisationUnits") + .addArray("value", orgUnits) + .build(); + + userActions.patch(loggedInUserId, Collections.singletonList(userPatch)).validateStatus(200); + } + + @Test + @DisplayName("CategoryOptionCombo merge fails when user has not got the required authority") + void categoryOptionComboMergeNoRequiredAuthTest() { + userActions.addUserFull("basic", "User", "basicUser", "Test1234!", "NO_AUTH"); + loginActions.loginAsUser("basicUser", "Test1234!"); + + // when + ApiResponse response = + categoryOptionComboApiActions.post("merge", getMergeBody("DISCARD")).validateStatus(403); + + // then + response + .validate() + .statusCode(403) + .body("httpStatus", equalTo("Forbidden")) + .body("status", equalTo("ERROR")) + .body( + "message", + equalTo( + "Access is denied, requires one Authority from [F_CATEGORY_OPTION_COMBO_MERGE]")); + } + + @Test + @DisplayName("Category Option Combo merge fails when min max DE DB unique key constraint met") + void dbConstraintMinMaxTest() { + // given + maintenanceApiActions + .post("categoryOptionComboUpdate", new QueryParamsBuilder().build()) + .validateStatus(204); + + // get cat opt combo uids for sources and target, after generating + sourceUid1 = getCocWithOptions("1A", "2A"); + sourceUid2 = getCocWithOptions("1B", "2B"); + targetUid = getCocWithOptions("3A", "4B"); + + String dataElement = setupDataElement("DE test 2"); + + setupMinMaxDataElements(sourceUid1, sourceUid2, targetUid, dataElement); + + // login as user with merge auth + loginActions.loginAsUser("userWithMergeAuth", "Test1234!"); + + // when + ApiResponse response = + categoryOptionComboApiActions.post("merge", getMergeBody("DISCARD")).validateStatus(409); + + // then + response + .validate() + .statusCode(409) + .body("httpStatus", equalTo("Conflict")) + .body("status", equalTo("ERROR")) + .body("message", containsString("ERROR: duplicate key value violates unique constraint")) + .body("message", containsString("minmaxdataelement_unique_key")); + } + + private void setupMetadata() { + metadataActions.importMetadata(metadata()).validateStatus(200); + } + + private void setupMinMaxDataElements( + String sourceUid1, String sourceUid2, String targetUid, String dataElement) { + minMaxActions.post(minMaxDataElements(sourceUid1, dataElement)); + minMaxActions.post(minMaxDataElements(sourceUid2, dataElement)); + minMaxActions.post(minMaxDataElements(targetUid, dataElement)); + } + + private String minMaxDataElements(String coc, String de) { + return """ + { + "min": 2, + "max": 11, + "generated": false, + "source": { + "id": "OrgUnitUid1" + }, + "dataElement": { + "id": "%s" + }, + "optionCombo": { + "id": "%s" + } + } + """ + .formatted(de, coc); + } + + private String setupDataElement(String name) { + return dataElementApiActions + .post( + """ + { + "aggregationType": "DEFAULT", + "domainType": "AGGREGATE", + "name": "%s", + "shortName": "%s", + "valueType": "TEXT" + } + """ + .formatted(name, name)) + .validateStatus(201) + .extractUid(); + } + + private JsonObject getMergeBody(String dataMergeStrategy) { + JsonObject json = new JsonObject(); + JsonArray sources = new JsonArray(); + sources.add(sourceUid1); + sources.add(sourceUid2); + json.add("sources", sources); + json.addProperty("target", targetUid); + json.addProperty("deleteSources", true); + json.addProperty("dataMergeStrategy", dataMergeStrategy); + return json; + } + + private String getCocWithOptions(String co1, String co2) { + + return categoryOptionComboApiActions + .get( + new QueryParamsBuilder() + .addAll("filter=name:like:%s".formatted(co1), "filter=name:like:%s".formatted(co2))) + .validate() + .extract() + .jsonPath() + .get("categoryOptionCombos[0].id") + .toString(); + } + + private String metadata() { + return """ + { + "categoryOptions": [ + { + "id": "CatOptUid1A", + "name": "cat opt 1A", + "shortName": "cat opt 1A", + "organisationUnits": [ + { + "id": "OrgUnitUid1" + }, + { + "id": "OrgUnitUid2" + } + ] + }, + { + "id": "CatOptUid1B", + "name": "cat opt 1B", + "shortName": "cat opt 1B", + "organisationUnits": [ + { + "id": "OrgUnitUid1" + }, + { + "id": "OrgUnitUid2" + } + ] + }, + { + "id": "CatOptUid2A", + "name": "cat opt 2A", + "shortName": "cat opt 2A", + "organisationUnits": [ + { + "id": "OrgUnitUid1" + }, + { + "id": "OrgUnitUid2" + } + ] + }, + { + "id": "CatOptUid2B", + "name": "cat opt 2B", + "shortName": "cat opt 2B", + "organisationUnits": [ + { + "id": "OrgUnitUid1" + }, + { + "id": "OrgUnitUid2" + } + ] + }, + { + "id": "CatOptUid3A", + "name": "cat opt 3A", + "shortName": "cat opt 3A", + "organisationUnits": [ + { + "id": "OrgUnitUid1" + }, + { + "id": "OrgUnitUid2" + } + ] + }, + { + "id": "CatOptUid3B", + "name": "cat opt 3B", + "shortName": "cat opt 3B", + "organisationUnits": [ + { + "id": "OrgUnitUid1" + }, + { + "id": "OrgUnitUid2" + } + ] + }, + { + "id": "CatOptUid4A", + "name": "cat opt 4A", + "shortName": "cat opt 4A", + "organisationUnits": [ + { + "id": "OrgUnitUid1" + }, + { + "id": "OrgUnitUid2" + } + ] + }, + { + "id": "CatOptUid4B", + "name": "cat opt 4B", + "shortName": "cat opt 4B", + "organisationUnits": [ + { + "id": "OrgUnitUid1" + }, + { + "id": "OrgUnitUid2" + } + ] + } + ], + "categories": [ + { + "id": "CategoUid01", + "name": "cat 1", + "shortName": "cat 1", + "dataDimensionType": "DISAGGREGATION", + "categoryOptions": [ + { + "id": "CatOptUid1A" + }, + { + "id": "CatOptUid1B" + } + ] + }, + { + "id": "CategoUid02", + "name": "cat 2", + "shortName": "cat 2", + "dataDimensionType": "DISAGGREGATION", + "categoryOptions": [ + { + "id": "CatOptUid2A" + }, + { + "id": "CatOptUid2B" + } + ] + }, + { + "id": "CategoUid03", + "name": "cat 3", + "shortName": "cat 3", + "dataDimensionType": "DISAGGREGATION", + "categoryOptions": [ + { + "id": "CatOptUid3A" + }, + { + "id": "CatOptUid3B" + } + ] + }, + { + "id": "CategoUid04", + "name": "cat 4", + "shortName": "cat 4", + "dataDimensionType": "DISAGGREGATION", + "categoryOptions": [ + { + "id": "CatOptUid4A" + }, + { + "id": "CatOptUid4B" + } + ] + } + ], + "organisationUnits": [ + { + "id": "OrgUnitUid1", + "name": "org 1", + "shortName": "org 1", + "openingDate": "2023-06-15", + "parent": { + "id": "DiszpKrYNg8" + } + }, + { + "id": "OrgUnitUid2", + "name": "org 2", + "shortName": "org 2", + "openingDate": "2024-06-15", + "parent": { + "id": "DiszpKrYNg8" + } + }, + { + "id": "OrgUnitUid3", + "name": "org 3", + "shortName": "org 3", + "openingDate": "2023-09-15", + "parent": { + "id": "DiszpKrYNg8" + } + }, + { + "id": "OrgUnitUid4", + "name": "org 4", + "shortName": "org 4", + "openingDate": "2023-06-25", + "parent": { + "id": "DiszpKrYNg8" + } + } + ], + "categoryOptionGroups": [ + { + "id": "CatOptGrp01", + "name": "cog 1", + "shortName": "cog 1", + "dataDimensionType": "DISAGGREGATION", + "categoryOptions": [ + { + "id": "CatOptUid1A" + }, + { + "id": "CatOptUid1B" + } + ] + }, + { + "id": "CatOptGrp02", + "name": "cog 2", + "shortName": "cog 2", + "dataDimensionType": "DISAGGREGATION", + "categoryOptions": [ + { + "id": "CatOptUid2A" + }, + { + "id": "CatOptUid2B" + } + ] + }, + { + "id": "CatOptGrp03", + "name": "cog 3", + "shortName": "cog 3", + "dataDimensionType": "DISAGGREGATION", + "categoryOptions": [ + { + "id": "CatOptUid3A" + }, + { + "id": "CatOptUid3B" + } + ] + }, + { + "id": "CatOptGrp04", + "name": "cog 4", + "shortName": "cog 4", + "dataDimensionType": "DISAGGREGATION", + "categoryOptions": [ + { + "id": "CatOptUid4A" + }, + { + "id": "CatOptUid4B" + } + ] + } + ], + "categoryCombos": [ + { + "id": "CatComUid01", + "name": "cat combo 1", + "dataDimensionType": "DISAGGREGATION", + "categories": [ + { + "id": "CategoUid01" + }, + { + "id": "CategoUid02" + } + ] + }, + { + "id": "CatComUid02", + "name": "cat combo 2", + "dataDimensionType": "DISAGGREGATION", + "categories": [ + { + "id": "CategoUid03" + }, + { + "id": "CategoUid04" + } + ] + } + ], + "dataElements": [ + { + "id": "deUid000001", + "aggregationType": "DEFAULT", + "domainType": "AGGREGATE", + "name": "DE for DVs", + "shortName": "DE for DVs", + "valueType": "TEXT" + } + ] + } + """; + } + + private JsonObject dataValueSetImportCoc( + String source1Coc, + String source2Coc, + String targetCoc, + String randomCoc1, + String randomCoc2) { + return JsonParserUtils.toJsonObject( + """ + { + "dataValues": [ + { + "dataElement": "deUid000001", + "period": "202405", + "orgUnit": "OrgUnitUid1", + "categoryOptionCombo": "%s", + "attributeOptionCombo": "HllvX50cXC0", + "value": "source 1, DV 1 - non duplicate earlier - KEEP", + "comment": "source 1, DV 1 - non duplicate earlier - KEEP" + }, + { + "dataElement": "deUid000001", + "period": "202408", + "orgUnit": "OrgUnitUid1", + "categoryOptionCombo": "%s", + "attributeOptionCombo": "HllvX50cXC0", + "value": "source 1, DV 2 - duplicate earlier - REMOVE", + "comment": "source 1, DV 2 - duplicate earlier - REMOVE" + }, + { + "dataElement": "deUid000001", + "period": "202409", + "orgUnit": "OrgUnitUid1", + "categoryOptionCombo": "%s", + "attributeOptionCombo": "HllvX50cXC0", + "value": "source 1, DV 3 - duplicate later - KEEP", + "comment": "source 1, DV 3 - duplicate later - KEEP" + }, + { + "dataElement": "deUid000001", + "period": "202407", + "orgUnit": "OrgUnitUid1", + "categoryOptionCombo": "%s", + "attributeOptionCombo": "HllvX50cXC0", + "value": "source 1, DV 4 - duplicate earlier - REMOVE", + "comment": "source 1, DV 4 - duplicate earlier - REMOVE" + }, + { + "dataElement": "deUid000001", + "period": "202410", + "orgUnit": "OrgUnitUid1", + "categoryOptionCombo": "%s", + "attributeOptionCombo": "HllvX50cXC0", + "value": "source 2, DV 1 - non duplicate later - KEEP", + "comment": "source 2, DV 1 - non duplicate later - KEEP" + }, + { + "dataElement": "deUid000001", + "period": "202408", + "orgUnit": "OrgUnitUid1", + "categoryOptionCombo": "%s", + "attributeOptionCombo": "HllvX50cXC0", + "value": "source 2, DV 2 - duplicate later - KEEP", + "comment": "source 2, DV 2 - duplicate later - KEEP" + }, + { + "dataElement": "deUid000001", + "period": "202409", + "orgUnit": "OrgUnitUid1", + "categoryOptionCombo": "%s", + "attributeOptionCombo": "HllvX50cXC0", + "value": "source 2, DV 3 - duplicate earlier - REMOVE", + "comment": "source 2, DV 3 - duplicate earlier - REMOVE" + }, + { + "dataElement": "deUid000001", + "period": "202407", + "orgUnit": "OrgUnitUid1", + "categoryOptionCombo": "%s", + "attributeOptionCombo": "HllvX50cXC0", + "value": "source 2, DV 4 - duplicate earlier - REMOVE", + "comment": "source 2, DV 4 - duplicate earlier - REMOVE" + }, + { + "dataElement": "deUid000001", + "period": "202408", + "orgUnit": "OrgUnitUid1", + "categoryOptionCombo": "%s", + "attributeOptionCombo": "HllvX50cXC0", + "value": "target DV 1 - duplicate earlier - REMOVE", + "comment": "target DV 1 - duplicate earlier - REMOVE" + }, + { + "dataElement": "deUid000001", + "period": "202409", + "orgUnit": "OrgUnitUid1", + "categoryOptionCombo": "%s", + "attributeOptionCombo": "HllvX50cXC0", + "value": "target DV 2 - duplicate earlier - REMOVE", + "comment": "target DV 2 - duplicate earlier - REMOVE" + }, + { + "dataElement": "deUid000001", + "period": "202403", + "orgUnit": "OrgUnitUid1", + "categoryOptionCombo": "%s", + "attributeOptionCombo": "HllvX50cXC0", + "value": "target DV 3 - not impacted - KEEP", + "comment": "target DV 3 - not impacted - KEEP" + }, + { + "dataElement": "deUid000001", + "period": "202407", + "orgUnit": "OrgUnitUid1", + "categoryOptionCombo": "%s", + "attributeOptionCombo": "HllvX50cXC0", + "value": "target DV 4 - duplicate later- KEEP", + "comment": "target DV 4 - duplicate later - KEEP" + }, + { + "dataElement": "deUid000001", + "period": "202408", + "orgUnit": "OrgUnitUid1", + "categoryOptionCombo": "%s", + "attributeOptionCombo": "HllvX50cXC0", + "value": "random 1, DV 1 - not impacted", + "comment": "random 1, DV 1 - not impacted" + }, + { + "dataElement": "deUid000001", + "period": "202408", + "orgUnit": "OrgUnitUid1", + "categoryOptionCombo": "%s", + "attributeOptionCombo": "HllvX50cXC0", + "value": "random 2, DV 2 - not impacted", + "comment": "random 2, DV 2 - not impacted" + } + ] + } + """ + .formatted( + source1Coc, + source1Coc, + source1Coc, + source1Coc, + source2Coc, + source2Coc, + source2Coc, + source2Coc, + targetCoc, + targetCoc, + targetCoc, + targetCoc, + randomCoc1, + randomCoc2)); + } + + private JsonObject dataValueSetImportAoc( + String source1Coc, + String source2Coc, + String targetCoc, + String randomCoc1, + String randomCoc2) { + return JsonParserUtils.toJsonObject( + """ + { + "dataValues": [ + { + "dataElement": "deUid000001", + "period": "202405", + "orgUnit": "OrgUnitUid2", + "categoryOptionCombo": "HllvX50cXC0", + "attributeOptionCombo": "%s", + "value": "source 1, DV 1 - non duplicate earlier - KEEP", + "comment": "source 1, DV 1 - non duplicate earlier - KEEP" + }, + { + "dataElement": "deUid000001", + "period": "202408", + "orgUnit": "OrgUnitUid2", + "categoryOptionCombo": "HllvX50cXC0", + "attributeOptionCombo": "%s", + "value": "source 1, DV 2 - duplicate earlier - REMOVE", + "comment": "source 1, DV 2 - duplicate earlier - REMOVE" + }, + { + "dataElement": "deUid000001", + "period": "202409", + "orgUnit": "OrgUnitUid2", + "categoryOptionCombo": "HllvX50cXC0", + "attributeOptionCombo": "%s", + "value": "source 1, DV 3 - duplicate later - KEEP", + "comment": "source 1, DV 3 - duplicate later - KEEP" + }, + { + "dataElement": "deUid000001", + "period": "202407", + "orgUnit": "OrgUnitUid2", + "categoryOptionCombo": "HllvX50cXC0", + "attributeOptionCombo": "%s", + "value": "source 1, DV 4 - duplicate earlier - REMOVE", + "comment": "source 1, DV 4 - duplicate earlier - REMOVE" + }, + { + "dataElement": "deUid000001", + "period": "202410", + "orgUnit": "OrgUnitUid2", + "categoryOptionCombo": "HllvX50cXC0", + "attributeOptionCombo": "%s", + "value": "source 2, DV 1 - non duplicate later - KEEP", + "comment": "source 2, DV 1 - non duplicate later - KEEP" + }, + { + "dataElement": "deUid000001", + "period": "202408", + "orgUnit": "OrgUnitUid2", + "categoryOptionCombo": "HllvX50cXC0", + "attributeOptionCombo": "%s", + "value": "source 2, DV 2 - duplicate later - KEEP", + "comment": "source 2, DV 2 - duplicate later - KEEP" + }, + { + "dataElement": "deUid000001", + "period": "202409", + "orgUnit": "OrgUnitUid2", + "categoryOptionCombo": "HllvX50cXC0", + "attributeOptionCombo": "%s", + "value": "source 2, DV 3 - duplicate earlier - REMOVE", + "comment": "source 2, DV 3 - duplicate earlier - REMOVE" + }, + { + "dataElement": "deUid000001", + "period": "202407", + "orgUnit": "OrgUnitUid2", + "categoryOptionCombo": "HllvX50cXC0", + "attributeOptionCombo": "%s", + "value": "source 2, DV 4 - duplicate earlier - REMOVE", + "comment": "source 2, DV 4 - duplicate earlier - REMOVE" + }, + { + "dataElement": "deUid000001", + "period": "202408", + "orgUnit": "OrgUnitUid2", + "categoryOptionCombo": "HllvX50cXC0", + "attributeOptionCombo": "%s", + "value": "target DV 1 - duplicate earlier - REMOVE", + "comment": "target DV 1 - duplicate earlier - REMOVE" + }, + { + "dataElement": "deUid000001", + "period": "202409", + "orgUnit": "OrgUnitUid2", + "categoryOptionCombo": "HllvX50cXC0", + "attributeOptionCombo": "%s", + "value": "target DV 2 - duplicate earlier - REMOVE", + "comment": "target DV 2 - duplicate earlier - REMOVE" + }, + { + "dataElement": "deUid000001", + "period": "202403", + "orgUnit": "OrgUnitUid2", + "categoryOptionCombo": "HllvX50cXC0", + "attributeOptionCombo": "%s", + "value": "target DV 3 - not impacted - KEEP", + "comment": "target DV 3 - not impacted - KEEP" + }, + { + "dataElement": "deUid000001", + "period": "202407", + "orgUnit": "OrgUnitUid2", + "categoryOptionCombo": "HllvX50cXC0", + "attributeOptionCombo": "%s", + "value": "target DV 4 - duplicate later- KEEP", + "comment": "target DV 4 - duplicate later - KEEP" + }, + { + "dataElement": "deUid000001", + "period": "202408", + "orgUnit": "OrgUnitUid2", + "categoryOptionCombo": "HllvX50cXC0", + "attributeOptionCombo": "%s", + "value": "random 1, DV 1 - not impacted", + "comment": "random 1, DV 1 - not impacted" + }, + { + "dataElement": "deUid000001", + "period": "202408", + "orgUnit": "OrgUnitUid2", + "categoryOptionCombo": "HllvX50cXC0", + "attributeOptionCombo": "%s", + "value": "random 2, DV 2 - not impacted", + "comment": "random 2, DV 2 - not impacted" + } + ] + } + """ + .formatted( + source1Coc, + source1Coc, + source1Coc, + source1Coc, + source2Coc, + source2Coc, + source2Coc, + source2Coc, + targetCoc, + targetCoc, + targetCoc, + targetCoc, + randomCoc1, + randomCoc2)); + } + + private JsonObject dataValueSetImportUpdateAoc( + String source1Coc, String source2Coc, String targetCoc) { + return JsonParserUtils.toJsonObject( + """ + { + "dataValues": [ + { + "dataElement": "deUid000001", + "period": "202409", + "orgUnit": "OrgUnitUid2", + "categoryOptionCombo": "HllvX50cXC0", + "attributeOptionCombo": "%s", + "value": "UPDATED source 1 DV 3 - duplicate later - KEEP", + "comment": "source 1, DV 3 - duplicate later - KEEP" + }, + { + "dataElement": "deUid000001", + "period": "202410", + "orgUnit": "OrgUnitUid2", + "categoryOptionCombo": "HllvX50cXC0", + "attributeOptionCombo": "%s", + "value": "UPDATED source 2 DV 1 - non duplicate later - KEEP", + "comment": "source 2, DV 1 - non duplicate later - KEEP" + }, + { + "dataElement": "deUid000001", + "period": "202408", + "orgUnit": "OrgUnitUid2", + "categoryOptionCombo": "HllvX50cXC0", + "attributeOptionCombo": "%s", + "value": "UPDATED source 2 DV 2 - duplicate later - KEEP", + "comment": "source 2, DV 2 - duplicate later - KEEP" + }, + { + "dataElement": "deUid000001", + "period": "202407", + "orgUnit": "OrgUnitUid2", + "categoryOptionCombo": "HllvX50cXC0", + "attributeOptionCombo": "%s", + "value": "UPDATED target DV 4 - duplicate later - KEEP", + "comment": "target DV 4 - duplicate later - KEEP" + } + ] + } + """ + .formatted(source1Coc, source2Coc, source2Coc, targetCoc)); + } + + private JsonObject dataValueSetImportUpdateCoc( + String source1Coc, String source2Coc, String targetCoc) { + return JsonParserUtils.toJsonObject( + """ + { + "dataValues": [ + { + "dataElement": "deUid000001", + "period": "202409", + "orgUnit": "OrgUnitUid1", + "categoryOptionCombo": "%s", + "attributeOptionCombo": "HllvX50cXC0", + "value": "UPDATED source 1 DV 3 - duplicate later - KEEP", + "comment": "source 1, DV 3 - duplicate later - KEEP" + }, + { + "dataElement": "deUid000001", + "period": "202410", + "orgUnit": "OrgUnitUid1", + "categoryOptionCombo": "%s", + "attributeOptionCombo": "HllvX50cXC0", + "value": "UPDATED source 2 DV 1 - non duplicate later - KEEP", + "comment": "source 2, DV 1 - non duplicate later - KEEP" + }, + { + "dataElement": "deUid000001", + "period": "202408", + "orgUnit": "OrgUnitUid1", + "categoryOptionCombo": "%s", + "attributeOptionCombo": "HllvX50cXC0", + "value": "UPDATED source 2 DV 2 - duplicate later - KEEP", + "comment": "source 2, DV 2 - duplicate later - KEEP" + }, + { + "dataElement": "deUid000001", + "period": "202407", + "orgUnit": "OrgUnitUid1", + "categoryOptionCombo": "%s", + "attributeOptionCombo": "HllvX50cXC0", + "value": "UPDATED target DV 4 - duplicate later - KEEP", + "comment": "target DV 4 - duplicate later - KEEP" + } + ] + } + """ + .formatted(source1Coc, source2Coc, source2Coc, targetCoc)); + } +} diff --git a/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/merge/CategoryOptionMergeTest.java b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/merge/CategoryOptionMergeTest.java index 27a858a6df52..2470a7c574fa 100644 --- a/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/merge/CategoryOptionMergeTest.java +++ b/dhis-2/dhis-test-e2e/src/test/java/org/hisp/dhis/merge/CategoryOptionMergeTest.java @@ -56,9 +56,9 @@ class CategoryOptionMergeTest extends ApiTest { private RestApiActions maintenanceApiActions; private UserActions userActions; private LoginActions loginActions; - private final String sourceUid1 = "CatOptUid1A"; - private final String sourceUid2 = "CatOptUid2B"; - private final String targetUid = "CatOptUid3A"; + private final String sourceUid1 = "UIDCatOpt1A"; + private final String sourceUid2 = "UIDCatOpt2B"; + private final String targetUid = "UIDCatOpt3A"; @BeforeAll public void before() { @@ -92,13 +92,9 @@ public void setup() { void validCategoryOptionMergeTest() { // given // generate category option combos - String emptyParams = new QueryParamsBuilder().build(); maintenanceApiActions - .post("categoryOptionComboUpdate/categoryCombo/CatComUid01", emptyParams) - .validateStatus(200); - maintenanceApiActions - .post("categoryOptionComboUpdate/categoryCombo/CatComUid02", emptyParams) - .validateStatus(200); + .post("categoryOptionComboUpdate", new QueryParamsBuilder().build()) + .validateStatus(204); // confirm state before merge ValidatableResponse preMergeState = @@ -108,7 +104,7 @@ void validCategoryOptionMergeTest() { .body("organisationUnits", hasSize(equalTo(1))) .body("organisationUnits", hasItem(hasEntry("id", "OrgUnitUid3"))) .body("categories", hasSize(equalTo(1))) - .body("categories", hasItem(hasEntry("id", "CategoUid03"))) + .body("categories", hasItem(hasEntry("id", "UIDCatego03"))) .body("categoryOptionCombos", hasSize(equalTo(2))) .body("categoryOptionGroups", hasSize(equalTo(1))) .body("categoryOptionGroups", hasItem(hasEntry("id", "CatOptGrp03"))); @@ -145,9 +141,9 @@ void validCategoryOptionMergeTest() { .body( "categories", hasItems( - hasEntry("id", "CategoUid01"), - hasEntry("id", "CategoUid02"), - hasEntry("id", "CategoUid03"))) + hasEntry("id", "UIDCatego01"), + hasEntry("id", "UIDCatego02"), + hasEntry("id", "UIDCatego03"))) .body("categoryOptionCombos", hasSize(equalTo(5))) .body( "categoryOptionGroups", @@ -158,7 +154,7 @@ void validCategoryOptionMergeTest() { } private void setupMetadata() { - metadataActions.post(metadata()).validateStatus(200); + metadataActions.importMetadata(metadata()).validateStatus(200); } @Test @@ -198,9 +194,9 @@ private String metadata() { { "categoryOptions": [ { - "id": "CatOptUid1A", - "name": "cat opt 1A", - "shortName": "cat opt 1A", + "id": "UIDCatOpt1A", + "name": "cat option 1A", + "shortName": "cat option 1A", "organisationUnits": [ { "id": "OrgUnitUid1" @@ -208,9 +204,9 @@ private String metadata() { ] }, { - "id": "CatOptUid1B", - "name": "cat opt 1B", - "shortName": "cat opt 1B", + "id": "UIDCatOpt1B", + "name": "cat option 1B", + "shortName": "cat option 1B", "organisationUnits": [ { "id": "OrgUnitUid1" @@ -218,9 +214,9 @@ private String metadata() { ] }, { - "id": "CatOptUid2A", - "name": "cat opt 2A", - "shortName": "cat opt 2A", + "id": "UIDCatOpt2A", + "name": "cat option 2A", + "shortName": "cat option 2A", "organisationUnits": [ { "id": "OrgUnitUid2" @@ -228,9 +224,9 @@ private String metadata() { ] }, { - "id": "CatOptUid2B", - "name": "cat opt 2B", - "shortName": "cat opt 2B", + "id": "UIDCatOpt2B", + "name": "cat option 2B", + "shortName": "cat option 2B", "organisationUnits": [ { "id": "OrgUnitUid2" @@ -238,9 +234,9 @@ private String metadata() { ] }, { - "id": "CatOptUid3A", - "name": "cat opt 3A", - "shortName": "cat opt 3A", + "id": "UIDCatOpt3A", + "name": "cat option 3A", + "shortName": "cat option 3A", "organisationUnits": [ { "id": "OrgUnitUid3" @@ -248,9 +244,9 @@ private String metadata() { ] }, { - "id": "CatOptUid3B", - "name": "cat opt 3B", - "shortName": "cat opt 3B", + "id": "UIDCatOpt3B", + "name": "cat option 3B", + "shortName": "cat option 3B", "organisationUnits": [ { "id": "OrgUnitUid3" @@ -258,9 +254,9 @@ private String metadata() { ] }, { - "id": "CatOptUid4A", - "name": "cat opt 4A", - "shortName": "cat opt 4A", + "id": "UIDCatOpt4A", + "name": "cat option 4A", + "shortName": "cat option 4A", "organisationUnits": [ { "id": "OrgUnitUid4" @@ -268,9 +264,9 @@ private String metadata() { ] }, { - "id": "CatOptUid4B", - "name": "cat opt 4B", - "shortName": "cat opt 4B", + "id": "UIDCatOpt4B", + "name": "cat option 4B", + "shortName": "cat option 4B", "organisationUnits": [ { "id": "OrgUnitUid4" @@ -280,58 +276,58 @@ private String metadata() { ], "categories": [ { - "id": "CategoUid01", - "name": "cat 1", - "shortName": "cat 1", + "id": "UIDCatego01", + "name": "category 1", + "shortName": "category 1", "dataDimensionType": "DISAGGREGATION", "categoryOptions": [ { - "id": "CatOptUid1A" + "id": "UIDCatOpt1A" }, { - "id": "CatOptUid1B" + "id": "UIDCatOpt1B" } ] }, { - "id": "CategoUid02", - "name": "cat 2", - "shortName": "cat 2", + "id": "UIDCatego02", + "name": "category 2", + "shortName": "category 2", "dataDimensionType": "DISAGGREGATION", "categoryOptions": [ { - "id": "CatOptUid2A" + "id": "UIDCatOpt2A" }, { - "id": "CatOptUid2B" + "id": "UIDCatOpt2B" } ] }, { - "id": "CategoUid03", - "name": "cat 3", - "shortName": "cat 3", + "id": "UIDCatego03", + "name": "category 3", + "shortName": "category 3", "dataDimensionType": "DISAGGREGATION", "categoryOptions": [ { - "id": "CatOptUid3A" + "id": "UIDCatOpt3A" }, { - "id": "CatOptUid3B" + "id": "UIDCatOpt3B" } ] }, { - "id": "CategoUid04", - "name": "cat 4", - "shortName": "cat 4", + "id": "UIDCatego04", + "name": "category 4", + "shortName": "category 4", "dataDimensionType": "DISAGGREGATION", "categoryOptions": [ { - "id": "CatOptUid4A" + "id": "UIDCatOpt4A" }, { - "id": "CatOptUid4B" + "id": "UIDCatOpt4B" } ] } @@ -365,85 +361,85 @@ private String metadata() { "categoryOptionGroups": [ { "id": "CatOptGrp01", - "name": "cog 1", - "shortName": "cog 1", + "name": "co group 1", + "shortName": "co group 1", "dataDimensionType": "DISAGGREGATION", "categoryOptions": [ { - "id": "CatOptUid1A" + "id": "UIDCatOpt1A" }, { - "id": "CatOptUid1B" + "id": "UIDCatOpt1B" } ] }, { "id": "CatOptGrp02", - "name": "cog 2", - "shortName": "cog 2", + "name": "co group 2", + "shortName": "co group 2", "dataDimensionType": "DISAGGREGATION", "categoryOptions": [ { - "id": "CatOptUid2A" + "id": "UIDCatOpt2A" }, { - "id": "CatOptUid2B" + "id": "UIDCatOpt2B" } ] }, { "id": "CatOptGrp03", - "name": "cog 3", - "shortName": "cog 3", + "name": "co group 3", + "shortName": "co group 3", "dataDimensionType": "DISAGGREGATION", "categoryOptions": [ { - "id": "CatOptUid3A" + "id": "UIDCatOpt3A" }, { - "id": "CatOptUid3B" + "id": "UIDCatOpt3B" } ] }, { "id": "CatOptGrp04", - "name": "cog 4", - "shortName": "cog 4", + "name": "co group 4", + "shortName": "co group 4", "dataDimensionType": "DISAGGREGATION", "categoryOptions": [ { - "id": "CatOptUid4A" + "id": "UIDCatOpt4A" }, { - "id": "CatOptUid4B" + "id": "UIDCatOpt4B" } ] } ], "categoryCombos": [ { - "id": "CatComUid01", - "name": "cat combo 1", + "id": "UIDCatCom01", + "name": "category combo 1", "dataDimensionType": "DISAGGREGATION", "categories": [ { - "id": "CategoUid01" + "id": "UIDCatego01" }, { - "id": "CategoUid02" + "id": "UIDCatego02" } ] }, { - "id": "CatComUid02", - "name": "cat combo 2", + "id": "UIDCatCom02", + "name": "category combo 2", "dataDimensionType": "DISAGGREGATION", "categories": [ { - "id": "CategoUid03" + "id": "UIDCatego03" }, { - "id": "CategoUid04" + "id": "UIDCatego04" } ] } diff --git a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/category/CategoryComboStoreTest.java b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/category/CategoryComboStoreTest.java new file mode 100644 index 000000000000..3868569b7471 --- /dev/null +++ b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/category/CategoryComboStoreTest.java @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2004-2024, 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: + * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 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. + * Neither the name of the HISP project 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.category; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import org.hisp.dhis.common.BaseIdentifiableObject; +import org.hisp.dhis.common.CodeGenerator; +import org.hisp.dhis.common.UID; +import org.hisp.dhis.test.integration.PostgresIntegrationTestBase; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; + +@Transactional +class CategoryComboStoreTest extends PostgresIntegrationTestBase { + @Autowired private CategoryComboStore categoryComboStore; + + @Test + @DisplayName("Retrieving CategoryCombos by CategoryOptionCombos returns the expected entries") + void getCatOptionComboTest() { + // given + CategoryOption co1 = createCategoryOption("1A", CodeGenerator.generateUid()); + CategoryOption co2 = createCategoryOption("1B", CodeGenerator.generateUid()); + CategoryOption co3 = createCategoryOption("2A", CodeGenerator.generateUid()); + CategoryOption co4 = createCategoryOption("2B", CodeGenerator.generateUid()); + CategoryOption co5 = createCategoryOption("3A", CodeGenerator.generateUid()); + CategoryOption co6 = createCategoryOption("4A", CodeGenerator.generateUid()); + categoryService.addCategoryOption(co1); + categoryService.addCategoryOption(co2); + categoryService.addCategoryOption(co3); + categoryService.addCategoryOption(co4); + categoryService.addCategoryOption(co5); + categoryService.addCategoryOption(co6); + + Category c1 = createCategory('1', co1, co2); + Category c2 = createCategory('2', co3, co4); + Category c3 = createCategory('3', co5); + Category c4 = createCategory('4', co6); + categoryService.addCategory(c1); + categoryService.addCategory(c2); + categoryService.addCategory(c3); + categoryService.addCategory(c4); + + CategoryCombo cc1 = createCategoryCombo('1', c1, c2); + CategoryCombo cc2 = createCategoryCombo('2', c3, c4); + categoryService.addCategoryCombo(cc1); + categoryService.addCategoryCombo(cc2); + + categoryService.generateOptionCombos(cc1); + categoryService.generateOptionCombos(cc2); + + CategoryOptionCombo coc1 = getCocWithOptions("1A", "2B"); + CategoryOptionCombo coc2 = getCocWithOptions("2A", "1B"); + + // when + List catCombosByCategoryOptionCombo = + categoryComboStore.getByCategoryOptionCombo(UID.of(coc1.getUid(), coc2.getUid())); + + // then + assertEquals(1, catCombosByCategoryOptionCombo.size(), "1 CategoryCombo should be present"); + List categoryCombos = + catCombosByCategoryOptionCombo.stream().map(BaseIdentifiableObject::getUid).toList(); + + assertTrue( + categoryCombos.contains(cc1.getUid()), + "Retrieved CategoryCombo UID should equal the expected value"); + } + + private CategoryOptionCombo getCocWithOptions(String co1, String co2) { + List allCategoryOptionCombos = + categoryService.getAllCategoryOptionCombos(); + + return allCategoryOptionCombos.stream() + .filter( + coc -> { + List categoryOptions = + coc.getCategoryOptions().stream().map(BaseIdentifiableObject::getName).toList(); + return categoryOptions.containsAll(List.of(co1, co2)); + }) + .toList() + .get(0); + } +} diff --git a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/category/CategoryOptionStoreTest.java b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/category/CategoryOptionStoreTest.java new file mode 100644 index 000000000000..b5b92e5e69fd --- /dev/null +++ b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/category/CategoryOptionStoreTest.java @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2004-2024, 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: + * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 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. + * Neither the name of the HISP project 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.category; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import org.hisp.dhis.common.BaseIdentifiableObject; +import org.hisp.dhis.common.CodeGenerator; +import org.hisp.dhis.common.UID; +import org.hisp.dhis.test.integration.PostgresIntegrationTestBase; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; + +@Transactional +class CategoryOptionStoreTest extends PostgresIntegrationTestBase { + @Autowired private CategoryOptionStore categoryOptionStore; + + @Test + @DisplayName("Retrieving CategoryOptions by CategoryOptionCombos returns the expected entries") + void getCatOptionComboTest() { + // given + CategoryOption co1 = createCategoryOption("1A", CodeGenerator.generateUid()); + CategoryOption co2 = createCategoryOption("1B", CodeGenerator.generateUid()); + CategoryOption co3 = createCategoryOption("2A", CodeGenerator.generateUid()); + CategoryOption co4 = createCategoryOption("2B", CodeGenerator.generateUid()); + CategoryOption co5 = createCategoryOption("3A", CodeGenerator.generateUid()); + CategoryOption co6 = createCategoryOption("4A", CodeGenerator.generateUid()); + categoryService.addCategoryOption(co1); + categoryService.addCategoryOption(co2); + categoryService.addCategoryOption(co3); + categoryService.addCategoryOption(co4); + categoryService.addCategoryOption(co5); + categoryService.addCategoryOption(co6); + + Category c1 = createCategory('1', co1, co2); + Category c2 = createCategory('2', co3, co4); + Category c3 = createCategory('3', co5); + Category c4 = createCategory('4', co6); + categoryService.addCategory(c1); + categoryService.addCategory(c2); + categoryService.addCategory(c3); + categoryService.addCategory(c4); + + CategoryCombo cc1 = createCategoryCombo('1', c1, c2); + CategoryCombo cc2 = createCategoryCombo('2', c3, c4); + categoryService.addCategoryCombo(cc1); + categoryService.addCategoryCombo(cc2); + + categoryService.generateOptionCombos(cc1); + categoryService.generateOptionCombos(cc2); + + CategoryOptionCombo coc1 = getCocWithOptions("1A", "2B"); + CategoryOptionCombo coc2 = getCocWithOptions("2A", "1B"); + + // when + List catOptionsByCategoryOptionCombo = + categoryOptionStore.getByCategoryOptionCombo(UID.of(coc1.getUid(), coc2.getUid())); + + // then + assertEquals(4, catOptionsByCategoryOptionCombo.size(), "4 CategoryOptions should be present"); + List categoryOptions = + catOptionsByCategoryOptionCombo.stream().map(BaseIdentifiableObject::getUid).toList(); + + assertTrue( + categoryOptions.containsAll( + List.of(co1.getUid(), co2.getUid(), co3.getUid(), co4.getUid())), + "Retrieved CategoryOption UIDs should have expected UIDs"); + } + + private CategoryOptionCombo getCocWithOptions(String co1, String co2) { + List allCategoryOptionCombos = + categoryService.getAllCategoryOptionCombos(); + + return allCategoryOptionCombos.stream() + .filter( + coc -> { + List categoryOptions = + coc.getCategoryOptions().stream().map(BaseIdentifiableObject::getName).toList(); + return categoryOptions.containsAll(List.of(co1, co2)); + }) + .toList() + .get(0); + } +} diff --git a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/dataapproval/DataApprovalAuditStoreTest.java b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/dataapproval/DataApprovalAuditStoreTest.java index b09308715cc0..5d66e204f7b7 100644 --- a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/dataapproval/DataApprovalAuditStoreTest.java +++ b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/dataapproval/DataApprovalAuditStoreTest.java @@ -49,6 +49,7 @@ import org.hisp.dhis.test.integration.PostgresIntegrationTestBase; import org.hisp.dhis.user.User; import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInstance; import org.junit.jupiter.api.TestInstance.Lifecycle; @@ -246,4 +247,45 @@ void TestGetDataApprovalAudits() { assertEquals(1, audits.size()); assertEquals(auditB, audits.get(0)); } + + @Test + @DisplayName("Deleting audits by category option combo deletes the correct entries") + void deleteByCocTest() { + // given + CategoryOptionCombo coc1 = createCategoryOptionCombo('1'); + coc1.setCategoryCombo(categoryService.getDefaultCategoryCombo()); + categoryService.addCategoryOptionCombo(coc1); + + CategoryOptionCombo coc2 = createCategoryOptionCombo('2'); + coc2.setCategoryCombo(categoryService.getDefaultCategoryCombo()); + categoryService.addCategoryOptionCombo(coc2); + + CategoryOptionCombo coc3 = createCategoryOptionCombo('3'); + coc3.setCategoryCombo(categoryService.getDefaultCategoryCombo()); + categoryService.addCategoryOptionCombo(coc3); + + DataApproval approvalX = + new DataApproval(level1, workflowA, periodA, sourceA, coc1, false, dateA, userA); + DataApproval approvalY = + new DataApproval(level2, workflowB, periodB, sourceB, coc2, false, dateB, userA); + DataApproval approvalZ = + new DataApproval(level2, workflowB, periodB, sourceA, coc3, false, dateB, userA); + + DataApprovalAudit auditA = new DataApprovalAudit(approvalX, APPROVE); + DataApprovalAudit auditB = new DataApprovalAudit(approvalY, UNAPPROVE); + DataApprovalAudit auditC = new DataApprovalAudit(approvalZ, UNAPPROVE); + dataApprovalAuditStore.save(auditA); + dataApprovalAuditStore.save(auditB); + dataApprovalAuditStore.save(auditC); + + // when + dataApprovalAuditStore.deleteDataApprovalAudits(coc1); + dataApprovalAuditStore.deleteDataApprovalAudits(coc2); + + // then + List audits = + dataApprovalAuditStore.getDataApprovalAudits(new DataApprovalAuditQueryParams()); + assertEquals(1, audits.size()); + assertEquals(auditC, audits.get(0)); + } } diff --git a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/dataapproval/DataApprovalStoreTest.java b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/dataapproval/DataApprovalStoreTest.java index 7ce448334a45..73a31773287f 100644 --- a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/dataapproval/DataApprovalStoreTest.java +++ b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/dataapproval/DataApprovalStoreTest.java @@ -31,11 +31,14 @@ import static org.junit.jupiter.api.Assertions.assertEquals; 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 java.util.Date; +import java.util.List; import org.hisp.dhis.category.CategoryCombo; import org.hisp.dhis.category.CategoryOptionCombo; import org.hisp.dhis.category.CategoryService; +import org.hisp.dhis.common.UID; import org.hisp.dhis.organisationunit.OrganisationUnit; import org.hisp.dhis.organisationunit.OrganisationUnitService; import org.hisp.dhis.period.Period; @@ -44,6 +47,7 @@ import org.hisp.dhis.test.integration.PostgresIntegrationTestBase; import org.hisp.dhis.user.User; import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInstance; import org.junit.jupiter.api.TestInstance.Lifecycle; @@ -250,4 +254,43 @@ void testDeleteDataApproval() { level2, workflowB12, periodB, sourceB, categoryOptionCombo); assertNull(dataApprovalB); } + + @Test + @DisplayName("Retrieving DataApprovals by CategoryOptionCombo returns expected results") + void getByCocTest() { + // given + CategoryOptionCombo coc1 = createCategoryOptionCombo('1'); + coc1.setCategoryCombo(categoryService.getDefaultCategoryCombo()); + categoryService.addCategoryOptionCombo(coc1); + + CategoryOptionCombo coc2 = createCategoryOptionCombo('2'); + coc2.setCategoryCombo(categoryService.getDefaultCategoryCombo()); + categoryService.addCategoryOptionCombo(coc2); + + CategoryOptionCombo coc3 = createCategoryOptionCombo('3'); + coc3.setCategoryCombo(categoryService.getDefaultCategoryCombo()); + categoryService.addCategoryOptionCombo(coc3); + + Date date = new Date(); + DataApproval dataApprovalA = + new DataApproval(level1, workflowA12, periodA, sourceA, coc1, false, date, userA); + DataApproval dataApprovalB = + new DataApproval(level2, workflowA12, periodA, sourceB, coc2, false, date, userA); + DataApproval dataApprovalC = + new DataApproval(level1, workflowA12, periodB, sourceA, coc3, false, date, userA); + + dataApprovalStore.addDataApproval(dataApprovalA); + dataApprovalStore.addDataApproval(dataApprovalB); + dataApprovalStore.addDataApproval(dataApprovalC); + + // when + List allByCoc = + dataApprovalStore.getByCategoryOptionCombo(UID.of(coc1.getUid(), coc2.getUid())); + + // then + assertEquals(2, allByCoc.size()); + assertTrue( + allByCoc.containsAll(List.of(dataApprovalA, dataApprovalB)), + "Retrieved result set should contain both DataApprovals"); + } } diff --git a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/dataelement/DataElementOperandStoreTest.java b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/dataelement/DataElementOperandStoreTest.java index 86e75b782688..1d5058120810 100644 --- a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/dataelement/DataElementOperandStoreTest.java +++ b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/dataelement/DataElementOperandStoreTest.java @@ -32,7 +32,9 @@ import java.util.List; import org.hisp.dhis.category.CategoryCombo; +import org.hisp.dhis.category.CategoryOptionCombo; import org.hisp.dhis.common.IdentifiableObjectManager; +import org.hisp.dhis.common.UID; import org.hisp.dhis.test.integration.PostgresIntegrationTestBase; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -78,12 +80,49 @@ void dataElementOperandByDataElementTest() { .containsAll(List.of(deW.getUid(), deX.getUid()))); } + @Test + @DisplayName("retrieving DataElementOperands by CategoryOptionCombo returns expected entries") + void dataElementOperandByCatOptComboTest() { + // given + CategoryCombo cc = createCategoryCombo("1", "CatComUid01"); + manager.save(cc); + + CategoryOptionCombo coc1 = createCategoryOptionCombo(cc); + CategoryOptionCombo coc2 = createCategoryOptionCombo(cc); + CategoryOptionCombo coc3 = createCategoryOptionCombo(cc); + CategoryOptionCombo coc4 = createCategoryOptionCombo(cc); + manager.save(List.of(coc1, coc2, coc3, coc4)); + + createDataElementOperandAndSave(coc1); + createDataElementOperandAndSave(coc2); + createDataElementOperandAndSave(coc3); + createDataElementOperandAndSave(coc4); + + // when + List dataElementOperands = + dataElementOperandStore.getByCategoryOptionCombo(UID.of(coc1.getUid(), coc2.getUid())); + + // then + assertEquals(2, dataElementOperands.size()); + assertTrue( + dataElementOperands.stream() + .map(deo -> deo.getCategoryOptionCombo().getUid()) + .toList() + .containsAll(List.of(coc1.getUid(), coc2.getUid()))); + } + private void createDataElementOperandAndSave(DataElement de) { DataElementOperand deo = new DataElementOperand(); deo.setDataElement(de); manager.save(deo); } + private void createDataElementOperandAndSave(CategoryOptionCombo coc) { + DataElementOperand deo = new DataElementOperand(); + deo.setCategoryOptionCombo(coc); + manager.save(deo); + } + private DataElement createDataElementAndSave(char c) { CategoryCombo cc = createCategoryCombo(c); manager.save(cc); diff --git a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/dataset/CompleteDataSetRegistrationStoreTest.java b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/dataset/CompleteDataSetRegistrationStoreTest.java new file mode 100644 index 000000000000..49a76639f08f --- /dev/null +++ b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/dataset/CompleteDataSetRegistrationStoreTest.java @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2004-2022, 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: + * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 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. + * Neither the name of the HISP project 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.dataset; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Date; +import java.util.List; +import org.hisp.dhis.category.CategoryOptionCombo; +import org.hisp.dhis.category.CategoryService; +import org.hisp.dhis.common.IdentifiableObjectManager; +import org.hisp.dhis.common.UID; +import org.hisp.dhis.dataelement.DataElement; +import org.hisp.dhis.organisationunit.OrganisationUnit; +import org.hisp.dhis.period.MonthlyPeriodType; +import org.hisp.dhis.period.Period; +import org.hisp.dhis.period.PeriodService; +import org.hisp.dhis.test.integration.PostgresIntegrationTestBase; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; + +/** + * @author david mackessy + */ +@Transactional +class CompleteDataSetRegistrationStoreTest extends PostgresIntegrationTestBase { + + @Autowired private CompleteDataSetRegistrationService completeDataSetRegistrationService; + @Autowired private CompleteDataSetRegistrationStore completeDataSetRegistrationStore; + @Autowired private DataSetService dataSetService; + @Autowired private PeriodService periodService; + @Autowired private IdentifiableObjectManager manager; + @Autowired private CategoryService categoryService; + + private DataElement elementA; + private DataElement elementB; + private DataElement elementC; + private DataSet dataSetA; + private DataSet dataSetB; + private DataSet dataSetC; + private Period periodA; + private Period periodB; + private OrganisationUnit sourceA; + private OrganisationUnit sourceB; + private OrganisationUnit sourceC; + + @BeforeEach + void setUp() { + sourceA = createOrganisationUnit('A'); + sourceB = createOrganisationUnit('B'); + sourceC = createOrganisationUnit('C'); + manager.save(List.of(sourceA, sourceB, sourceC)); + + periodA = createPeriod(new MonthlyPeriodType(), getDate(2000, 1, 1), getDate(2000, 1, 31)); + periodB = createPeriod(new MonthlyPeriodType(), getDate(2000, 2, 1), getDate(2000, 2, 28)); + periodService.addPeriod(periodA); + periodService.addPeriod(periodB); + + elementA = createDataElement('A'); + elementB = createDataElement('B'); + elementC = createDataElement('C'); + manager.save(List.of(elementA, elementB, elementC)); + + dataSetA = createDataSet('A', new MonthlyPeriodType()); + dataSetB = createDataSet('B', new MonthlyPeriodType()); + dataSetC = createDataSet('C', new MonthlyPeriodType()); + dataSetA.addDataSetElement(elementA); + dataSetB.addDataSetElement(elementB); + dataSetC.addDataSetElement(elementC); + + dataSetA.getSources().add(sourceA); + dataSetB.getSources().add(sourceB); + dataSetC.getSources().add(sourceA); + dataSetService.addDataSet(dataSetA); + dataSetService.addDataSet(dataSetB); + dataSetService.addDataSet(dataSetC); + } + + @Test + @DisplayName("Get all CompleteDataSetRegistration by CategoryOptionCombo") + void testSaveGet() { + // given + CategoryOptionCombo aoc1 = createCategoryOptionCombo('1'); + aoc1.setCategoryCombo(categoryService.getDefaultCategoryCombo()); + categoryService.addCategoryOptionCombo(aoc1); + + CategoryOptionCombo aoc2 = createCategoryOptionCombo('2'); + aoc2.setCategoryCombo(categoryService.getDefaultCategoryCombo()); + categoryService.addCategoryOptionCombo(aoc2); + + CategoryOptionCombo aoc3 = createCategoryOptionCombo('3'); + aoc3.setCategoryCombo(categoryService.getDefaultCategoryCombo()); + categoryService.addCategoryOptionCombo(aoc3); + + CompleteDataSetRegistration registrationA = + new CompleteDataSetRegistration( + dataSetA, periodA, sourceA, aoc1, new Date(), "", new Date(), "", true); + CompleteDataSetRegistration registrationB = + new CompleteDataSetRegistration( + dataSetB, periodB, sourceA, aoc2, new Date(), "", new Date(), "", true); + CompleteDataSetRegistration registrationC = + new CompleteDataSetRegistration( + dataSetC, periodB, sourceB, aoc3, new Date(), "", new Date(), "", true); + completeDataSetRegistrationService.saveCompleteDataSetRegistration(registrationA); + completeDataSetRegistrationService.saveCompleteDataSetRegistration(registrationB); + completeDataSetRegistrationService.saveCompleteDataSetRegistration(registrationC); + + // when + List allByCategoryOptionCombo = + completeDataSetRegistrationStore.getAllByCategoryOptionCombo( + UID.of(aoc1.getUid(), aoc2.getUid())); + assertEquals(2, allByCategoryOptionCombo.size()); + assertTrue( + allByCategoryOptionCombo.containsAll(List.of(registrationA, registrationB)), + "retrieved registrations contains 2 registrations referencing the 2 attribute opt combos passed in"); + assertFalse( + allByCategoryOptionCombo.contains(registrationC), + "retrieved registrations do not contain a registration referencing a AOC not used in the query"); + } +} diff --git a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/datavalue/DataValueAuditStoreTest.java b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/datavalue/DataValueAuditStoreTest.java new file mode 100644 index 000000000000..027c142d4d9d --- /dev/null +++ b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/datavalue/DataValueAuditStoreTest.java @@ -0,0 +1,213 @@ +/* + * Copyright (c) 2004-2024, 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: + * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 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. + * Neither the name of the HISP project 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.datavalue; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import org.hisp.dhis.audit.AuditOperationType; +import org.hisp.dhis.category.CategoryOptionCombo; +import org.hisp.dhis.category.CategoryService; +import org.hisp.dhis.common.IdentifiableObjectManager; +import org.hisp.dhis.dataelement.DataElement; +import org.hisp.dhis.organisationunit.OrganisationUnit; +import org.hisp.dhis.period.MonthlyPeriodType; +import org.hisp.dhis.period.Period; +import org.hisp.dhis.period.PeriodService; +import org.hisp.dhis.test.integration.PostgresIntegrationTestBase; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; + +@Transactional +class DataValueAuditStoreTest extends PostgresIntegrationTestBase { + + @Autowired private DataValueAuditService dataValueAuditService; + @Autowired private DataValueAuditStore dataValueAuditStore; + @Autowired private DataValueService dataValueService; + @Autowired private IdentifiableObjectManager manager; + @Autowired private CategoryService categoryService; + @Autowired private PeriodService periodService; + + private DataValue dataValueA1; + private DataValue dataValueA2; + private DataValue dataValueB1; + private DataValue dataValueB2; + private DataValue dataValueC1; + private DataValue dataValueC2; + private CategoryOptionCombo coc1; + private CategoryOptionCombo coc2; + private CategoryOptionCombo coc3; + + @BeforeEach + void setUp() { + coc1 = createCategoryOptionCombo('1'); + coc1.setCategoryCombo(categoryService.getDefaultCategoryCombo()); + categoryService.addCategoryOptionCombo(coc1); + + coc2 = createCategoryOptionCombo('2'); + coc2.setCategoryCombo(categoryService.getDefaultCategoryCombo()); + categoryService.addCategoryOptionCombo(coc2); + + coc3 = createCategoryOptionCombo('3'); + coc3.setCategoryCombo(categoryService.getDefaultCategoryCombo()); + categoryService.addCategoryOptionCombo(coc3); + + DataElement dataElementA = createDataElement('A'); + DataElement dataElementB = createDataElement('B'); + DataElement dataElementC = createDataElement('C'); + manager.save(List.of(dataElementA, dataElementB, dataElementC)); + + Period periodA = + createPeriod(new MonthlyPeriodType(), getDate(2017, 1, 1), getDate(2017, 1, 31)); + Period periodB = + createPeriod(new MonthlyPeriodType(), getDate(2018, 1, 1), getDate(2017, 1, 31)); + Period periodC = + createPeriod(new MonthlyPeriodType(), getDate(2019, 1, 1), getDate(2017, 1, 31)); + periodService.addPeriod(periodA); + periodService.addPeriod(periodB); + periodService.addPeriod(periodC); + + OrganisationUnit orgUnitA = createOrganisationUnit('A'); + OrganisationUnit orgUnitB = createOrganisationUnit('B'); + OrganisationUnit orgUnitC = createOrganisationUnit('C'); + manager.save(List.of(orgUnitA, orgUnitB, orgUnitC)); + + dataValueA1 = createDataValue(dataElementA, periodA, orgUnitA, coc1, coc1, "1"); + dataValueA2 = createDataValue(dataElementA, periodB, orgUnitA, coc1, coc1, "2"); + dataValueB1 = createDataValue(dataElementB, periodB, orgUnitB, coc2, coc2, "3"); + dataValueB2 = createDataValue(dataElementB, periodC, orgUnitB, coc2, coc2, "4"); + dataValueC1 = createDataValue(dataElementC, periodC, orgUnitC, coc3, coc3, "5"); + dataValueC2 = createDataValue(dataElementC, periodA, orgUnitC, coc3, coc3, "6"); + dataValueService.addDataValue(dataValueA1); + dataValueService.addDataValue(dataValueA2); + dataValueService.addDataValue(dataValueB1); + dataValueService.addDataValue(dataValueB2); + dataValueService.addDataValue(dataValueC1); + dataValueService.addDataValue(dataValueC2); + } + + @Test + @DisplayName("Deleting audits by category option combo deletes the correct entries") + void testAddGetDataValueAuditFromDataValue() { + // given + DataValueAudit dataValueAuditA1 = + new DataValueAudit( + dataValueA1, + dataValueA1.getValue(), + dataValueA1.getStoredBy(), + AuditOperationType.UPDATE); + dataValueAuditA1.setCategoryOptionCombo(coc1); + DataValueAudit dataValueAuditA2 = + new DataValueAudit( + dataValueA2, + dataValueA2.getValue(), + dataValueA2.getStoredBy(), + AuditOperationType.UPDATE); + dataValueAuditA2.setAttributeOptionCombo(coc1); + DataValueAudit dataValueAuditB1 = + new DataValueAudit( + dataValueB1, + dataValueB1.getValue(), + dataValueB1.getStoredBy(), + AuditOperationType.UPDATE); + dataValueAuditB1.setCategoryOptionCombo(coc2); + DataValueAudit dataValueAuditB2 = + new DataValueAudit( + dataValueB2, + dataValueB2.getValue(), + dataValueB2.getStoredBy(), + AuditOperationType.UPDATE); + dataValueAuditB2.setAttributeOptionCombo(coc2); + DataValueAudit dataValueAuditC1 = + new DataValueAudit( + dataValueC1, + dataValueC1.getValue(), + dataValueC1.getStoredBy(), + AuditOperationType.UPDATE); + dataValueAuditC1.setCategoryOptionCombo(coc3); + DataValueAudit dataValueAuditC2 = + new DataValueAudit( + dataValueC2, + dataValueC2.getValue(), + dataValueC2.getStoredBy(), + AuditOperationType.UPDATE); + dataValueAuditC2.setAttributeOptionCombo(coc3); + + dataValueAuditService.addDataValueAudit(dataValueAuditA1); + dataValueAuditService.addDataValueAudit(dataValueAuditA2); + dataValueAuditService.addDataValueAudit(dataValueAuditB1); + dataValueAuditService.addDataValueAudit(dataValueAuditB2); + dataValueAuditService.addDataValueAudit(dataValueAuditC1); + dataValueAuditService.addDataValueAudit(dataValueAuditC2); + + // state before delete + List dvaCoc1Before = + dataValueAuditStore.getDataValueAudits( + new DataValueAuditQueryParams().setCategoryOptionCombo(coc1)); + List dvaCoc2Before = + dataValueAuditStore.getDataValueAudits( + new DataValueAuditQueryParams().setAttributeOptionCombo(coc2)); + List dvaCoc3Before = + dataValueAuditStore.getDataValueAudits( + new DataValueAuditQueryParams() + .setCategoryOptionCombo(coc3) + .setAttributeOptionCombo(coc3)); + + assertEquals(2, dvaCoc1Before.size(), "There should be 2 audits referencing Cat Opt Combo 1"); + assertEquals(2, dvaCoc2Before.size(), "There should be 2 audits referencing Cat Opt Combo 2"); + assertEquals(2, dvaCoc3Before.size(), "There should be 2 audits referencing Cat Opt Combo 3"); + + // when + dataValueAuditStore.deleteDataValueAudits(coc1); + dataValueAuditStore.deleteDataValueAudits(coc2); + + // then + List dvaCoc1After = + dataValueAuditStore.getDataValueAudits( + new DataValueAuditQueryParams().setCategoryOptionCombo(coc1)); + List dvaCoc2After = + dataValueAuditStore.getDataValueAudits( + new DataValueAuditQueryParams().setAttributeOptionCombo(coc2)); + List dvaCoc3After = + dataValueAuditStore.getDataValueAudits( + new DataValueAuditQueryParams() + .setCategoryOptionCombo(coc3) + .setAttributeOptionCombo(coc3)); + + assertTrue(dvaCoc1After.isEmpty(), "There should be 0 audits referencing Cat Opt Combo 1"); + assertTrue(dvaCoc2After.isEmpty(), "There should be 0 audits referencing Cat Opt Combo 2"); + assertEquals(2, dvaCoc3After.size(), "There should be 2 audits referencing Cat Opt Combo 3"); + assertTrue( + dvaCoc3After.containsAll(List.of(dataValueAuditC1, dataValueAuditC2)), + "Retrieved entries should contain both audits referencing cat opt combo 3"); + } +} diff --git a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/datavalue/DataValueStoreTest.java b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/datavalue/DataValueStoreTest.java index 413d87e35c8e..2a2db46c84f9 100644 --- a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/datavalue/DataValueStoreTest.java +++ b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/datavalue/DataValueStoreTest.java @@ -36,14 +36,17 @@ import jakarta.persistence.PersistenceContext; import java.util.Date; import java.util.List; +import java.util.stream.Collectors; import org.hisp.dhis.category.CategoryOption; import org.hisp.dhis.category.CategoryOptionCombo; +import org.hisp.dhis.common.UID; import org.hisp.dhis.common.ValueType; import org.hisp.dhis.dataelement.DataElement; import org.hisp.dhis.organisationunit.OrganisationUnit; import org.hisp.dhis.period.Period; import org.hisp.dhis.period.PeriodType; import org.hisp.dhis.period.PeriodTypeEnum; +import org.hisp.dhis.test.api.TestCategoryMetadata; import org.hisp.dhis.test.integration.PostgresIntegrationTestBase; import org.hisp.dhis.util.DateUtils; import org.junit.jupiter.api.DisplayName; @@ -126,6 +129,381 @@ void getDataValuesByDataElement() { "retrieved data values do not contain a data value referencing any of the 2 data elements passed in"); } + @Test + @DisplayName("Get all DataValues by CategoryOptionCombo") + void getDataValuesByCoc() { + // given + Period p1 = + createPeriod(DateUtils.getDate(2023, 1, 1, 1, 1), DateUtils.getDate(2023, 2, 1, 1, 1)); + Period p2 = + createPeriod(DateUtils.getDate(2023, 3, 1, 1, 1), DateUtils.getDate(2023, 4, 1, 1, 1)); + Period p3 = + createPeriod(DateUtils.getDate(2023, 5, 1, 1, 1), DateUtils.getDate(2023, 6, 1, 1, 1)); + + CategoryOptionCombo coc1 = createCategoryOptionCombo('1'); + coc1.setCategoryCombo(categoryService.getDefaultCategoryCombo()); + categoryService.addCategoryOptionCombo(coc1); + + CategoryOptionCombo coc2 = createCategoryOptionCombo('2'); + coc2.setCategoryCombo(categoryService.getDefaultCategoryCombo()); + categoryService.addCategoryOptionCombo(coc2); + + CategoryOptionCombo coc3 = createCategoryOptionCombo('3'); + coc3.setCategoryCombo(categoryService.getDefaultCategoryCombo()); + categoryService.addCategoryOptionCombo(coc3); + + DataValue dv1 = createDataValue('B', p1, "dv test 1"); + dv1.setCategoryOptionCombo(coc1); + DataValue dv2 = createDataValue('C', p2, "dv test 2"); + dv2.setCategoryOptionCombo(coc2); + DataValue dv3 = createDataValue('D', p3, "dv test 3"); + dv3.setCategoryOptionCombo(coc3); + + dataValueStore.addDataValue(dv1); + dataValueStore.addDataValue(dv2); + dataValueStore.addDataValue(dv3); + + // when + List allDataValuesByCoc = + dataValueStore.getAllDataValuesByCatOptCombo(UID.of(coc1, coc2)); + + // then + assertEquals(2, allDataValuesByCoc.size()); + assertTrue( + allDataValuesByCoc.containsAll(List.of(dv1, dv2)), + "retrieved data values contain 2 data values referencing the 2 category opt combos passed in"); + assertFalse( + allDataValuesByCoc.contains(dv3), + "retrieved data values do not contain a data value referencing a COC not used in the query"); + } + + @Test + @DisplayName("Get all DataValues by AttributeOptionCombo") + void getDataValuesByAoc() { + // given + Period p1 = + createPeriod(DateUtils.getDate(2023, 1, 1, 1, 1), DateUtils.getDate(2023, 2, 1, 1, 1)); + Period p2 = + createPeriod(DateUtils.getDate(2023, 3, 1, 1, 1), DateUtils.getDate(2023, 4, 1, 1, 1)); + Period p3 = + createPeriod(DateUtils.getDate(2023, 5, 1, 1, 1), DateUtils.getDate(2023, 6, 1, 1, 1)); + + CategoryOptionCombo aoc1 = createCategoryOptionCombo('1'); + aoc1.setCategoryCombo(categoryService.getDefaultCategoryCombo()); + categoryService.addCategoryOptionCombo(aoc1); + + CategoryOptionCombo aoc2 = createCategoryOptionCombo('2'); + aoc2.setCategoryCombo(categoryService.getDefaultCategoryCombo()); + categoryService.addCategoryOptionCombo(aoc2); + + CategoryOptionCombo aoc3 = createCategoryOptionCombo('3'); + aoc3.setCategoryCombo(categoryService.getDefaultCategoryCombo()); + categoryService.addCategoryOptionCombo(aoc3); + + DataValue dv1 = createDataValue('B', p1, "dv test 1"); + dv1.setAttributeOptionCombo(aoc1); + DataValue dv2 = createDataValue('C', p2, "dv test 2"); + dv2.setAttributeOptionCombo(aoc2); + DataValue dv3 = createDataValue('D', p3, "dv test 3"); + dv3.setAttributeOptionCombo(aoc3); + + dataValueStore.addDataValue(dv1); + dataValueStore.addDataValue(dv2); + dataValueStore.addDataValue(dv3); + + // when + List allDataValuesByAoc = + dataValueStore.getAllDataValuesByAttrOptCombo(UID.of(aoc1, aoc2)); + + // then + assertEquals(2, allDataValuesByAoc.size()); + assertTrue( + allDataValuesByAoc.containsAll(List.of(dv1, dv2)), + "retrieved data values contain 2 data values referencing the 2 attribute opt combos passed in"); + assertFalse( + allDataValuesByAoc.contains(dv3), + "retrieved data values do not contain a data value referencing a AOC not used in the query"); + } + + @Test + @DisplayName( + "Merging duplicate DataValues (cat opt combos) leaves only the last updated (source) value remaining") + void mergeDvWithDuplicatesKeepSource() { + // given + TestCategoryMetadata categoryMetadata = setupCategoryMetadata(); + + Period p1 = createPeriod(DateUtils.getDate(2024, 1, 1), DateUtils.getDate(2023, 2, 1)); + + DataElement de = createDataElement('z'); + manager.persist(de); + + OrganisationUnit ou = createOrganisationUnit("org u 1"); + manager.persist(ou); + + // data values with same period, org unit, data element and attr opt combo + // which will be identified as duplicates during merging + DataValue dv1 = createDataValue('1', p1, "dv test 1"); + dv1.setCategoryOptionCombo(categoryMetadata.coc1()); + dv1.setAttributeOptionCombo(categoryMetadata.coc4()); + dv1.setDataElement(de); + dv1.setSource(ou); + dv1.setLastUpdated(DateUtils.parseDate("2024-12-01")); + + DataValue dv2 = createDataValue('2', p1, "dv test 2 - last updated"); + dv2.setCategoryOptionCombo(categoryMetadata.coc2()); + dv2.setAttributeOptionCombo(categoryMetadata.coc4()); + dv2.setDataElement(de); + dv2.setSource(ou); + dv2.setLastUpdated(DateUtils.parseDate("2025-01-08")); + + DataValue dv3 = createDataValue('3', p1, "dv test 3"); + dv3.setCategoryOptionCombo(categoryMetadata.coc3()); + dv3.setAttributeOptionCombo(categoryMetadata.coc4()); + dv3.setDataElement(de); + dv3.setSource(ou); + dv3.setLastUpdated(DateUtils.parseDate("2024-12-06")); + + DataValue dv4 = createDataValue('4', p1, "dv test 4, untouched"); + dv4.setCategoryOptionCombo(categoryMetadata.coc4()); + dv4.setAttributeOptionCombo(categoryMetadata.coc4()); + dv4.setDataElement(de); + dv4.setSource(ou); + dv4.setLastUpdated(DateUtils.parseDate("2024-11-02")); + + addDataValues(dv1, dv2, dv3, dv4); + + // check pre merge state + List preMergeState = dataValueStore.getAllDataValuesByDataElement(List.of(de)); + + assertEquals(4, preMergeState.size(), "there should be 4 data values"); + checkCocIdsPresent( + preMergeState, + List.of( + categoryMetadata.coc1().getId(), + categoryMetadata.coc2().getId(), + categoryMetadata.coc3().getId(), + categoryMetadata.coc4().getId())); + + // when + mergeDataValues( + categoryMetadata.coc3(), List.of(categoryMetadata.coc1(), categoryMetadata.coc2())); + + // then + List postMergeState = dataValueStore.getAllDataValuesByDataElement(List.of(de)); + + assertEquals(2, postMergeState.size(), "there should be 2 data values"); + checkCocIdsPresent( + preMergeState, List.of(categoryMetadata.coc3().getId(), categoryMetadata.coc4().getId())); + + checkDataValuesPresent( + postMergeState, List.of("dv test 2 - last updated", "dv test 4, untouched")); + + checkDatesPresent( + postMergeState, + List.of(DateUtils.parseDate("2025-01-08"), DateUtils.parseDate("2024-11-02"))); + } + + @Test + @DisplayName( + "Merging duplicate DataValues (cat opt combos) leaves only the last updated (target) value remaining") + void mergeDvWithDuplicatesKeepTarget() { + // given + TestCategoryMetadata categoryMetadata = setupCategoryMetadata(); + + Period p1 = createPeriod(DateUtils.getDate(2024, 1, 1), DateUtils.getDate(2023, 2, 1)); + + DataElement de = createDataElement('z'); + manager.persist(de); + + OrganisationUnit ou = createOrganisationUnit("org u 1"); + manager.persist(ou); + + // data values with same period, org unit, data element and attr opt combo + // which will be identified as duplicates during merging + DataValue dv1 = createDataValue('1', p1, "dv test 1"); + dv1.setCategoryOptionCombo(categoryMetadata.coc1()); + dv1.setAttributeOptionCombo(categoryMetadata.coc4()); + dv1.setDataElement(de); + dv1.setSource(ou); + dv1.setLastUpdated(DateUtils.parseDate("2024-12-01")); + + DataValue dv2 = createDataValue('2', p1, "dv test 2"); + dv2.setCategoryOptionCombo(categoryMetadata.coc2()); + dv2.setAttributeOptionCombo(categoryMetadata.coc4()); + dv2.setDataElement(de); + dv2.setSource(ou); + dv2.setLastUpdated(DateUtils.parseDate("2025-01-02")); + + DataValue dv3 = createDataValue('3', p1, "dv test 3 - last updated"); + dv3.setCategoryOptionCombo(categoryMetadata.coc3()); + dv3.setAttributeOptionCombo(categoryMetadata.coc4()); + dv3.setDataElement(de); + dv3.setSource(ou); + dv3.setLastUpdated(DateUtils.parseDate("2025-01-06")); + + DataValue dv4 = createDataValue('4', p1, "dv test 4, untouched"); + dv4.setCategoryOptionCombo(categoryMetadata.coc4()); + dv4.setAttributeOptionCombo(categoryMetadata.coc4()); + dv4.setDataElement(de); + dv4.setSource(ou); + dv4.setLastUpdated(DateUtils.parseDate("2024-11-02")); + + addDataValues(dv1, dv2, dv3, dv4); + + // check pre merge state + List preMergeState = dataValueStore.getAllDataValuesByDataElement(List.of(de)); + + assertEquals(4, preMergeState.size(), "there should be 4 data values"); + checkCocIdsPresent( + preMergeState, + List.of( + categoryMetadata.coc1().getId(), + categoryMetadata.coc2().getId(), + categoryMetadata.coc3().getId(), + categoryMetadata.coc4().getId())); + + // when + mergeDataValues( + categoryMetadata.coc3(), List.of(categoryMetadata.coc1(), categoryMetadata.coc2())); + + // then + List postMergeState = dataValueStore.getAllDataValuesByDataElement(List.of(de)); + + assertEquals(2, postMergeState.size(), "there should be 2 data values"); + checkCocIdsPresent( + postMergeState, List.of(categoryMetadata.coc3().getId(), categoryMetadata.coc4().getId())); + + checkDataValuesPresent( + postMergeState, List.of("dv test 3 - last updated", "dv test 4, untouched")); + + checkDatesPresent( + postMergeState, + List.of(DateUtils.parseDate("2025-01-06"), DateUtils.parseDate("2024-11-02"))); + } + + @Test + @DisplayName( + "Merging non-duplicate DataValues (cat opt combos) updates the cat opt combo value only") + void mergeDvWithNoDuplicates() { + // given + TestCategoryMetadata categoryMetadata = setupCategoryMetadata(); + + Period p1 = createPeriod(DateUtils.getDate(2024, 1, 1), DateUtils.getDate(2023, 2, 1)); + Period p2 = createPeriod(DateUtils.getDate(2024, 2, 1), DateUtils.getDate(2023, 3, 1)); + Period p3 = createPeriod(DateUtils.getDate(2024, 3, 1), DateUtils.getDate(2023, 4, 1)); + Period p4 = createPeriod(DateUtils.getDate(2024, 4, 1), DateUtils.getDate(2023, 5, 1)); + + DataElement de = createDataElement('z'); + manager.persist(de); + + OrganisationUnit ou = createOrganisationUnit("org u 1"); + manager.persist(ou); + + // data values with different period, so no duplicates detected during merging + DataValue dv1 = createDataValue('1', p1, "dv test 1"); + dv1.setCategoryOptionCombo(categoryMetadata.coc1()); + dv1.setAttributeOptionCombo(categoryMetadata.coc4()); + dv1.setDataElement(de); + dv1.setSource(ou); + dv1.setLastUpdated(DateUtils.parseDate("2024-12-01")); + + DataValue dv2 = createDataValue('2', p2, "dv test 2 - last updated"); + dv2.setCategoryOptionCombo(categoryMetadata.coc2()); + dv2.setAttributeOptionCombo(categoryMetadata.coc4()); + dv2.setDataElement(de); + dv2.setSource(ou); + dv2.setLastUpdated(DateUtils.parseDate("2025-01-08")); + + DataValue dv3 = createDataValue('3', p3, "dv test 3"); + dv3.setCategoryOptionCombo(categoryMetadata.coc3()); + dv3.setAttributeOptionCombo(categoryMetadata.coc4()); + dv3.setDataElement(de); + dv3.setSource(ou); + dv3.setLastUpdated(DateUtils.parseDate("2024-12-06")); + + DataValue dv4 = createDataValue('4', p4, "dv test 4, untouched"); + dv4.setCategoryOptionCombo(categoryMetadata.coc4()); + dv4.setAttributeOptionCombo(categoryMetadata.coc4()); + dv4.setDataElement(de); + dv4.setSource(ou); + dv4.setLastUpdated(DateUtils.parseDate("2024-11-02")); + + addDataValues(dv1, dv2, dv3, dv4); + + // check pre merge state + List preMergeState = dataValueStore.getAllDataValuesByDataElement(List.of(de)); + + assertEquals(4, preMergeState.size(), "there should be 4 data values"); + checkCocIdsPresent( + preMergeState, + List.of( + categoryMetadata.coc1().getId(), + categoryMetadata.coc2().getId(), + categoryMetadata.coc3().getId(), + categoryMetadata.coc4().getId())); + + // when + mergeDataValues( + categoryMetadata.coc3(), List.of(categoryMetadata.coc1(), categoryMetadata.coc2())); + + // then + List postMergeState = dataValueStore.getAllDataValuesByDataElement(List.of(de)); + + assertEquals(4, postMergeState.size(), "there should still be 4 data values"); + checkCocIdsPresent( + postMergeState, List.of(categoryMetadata.coc3().getId(), categoryMetadata.coc4().getId())); + + checkDataValuesPresent( + postMergeState, + List.of("dv test 1", "dv test 2 - last updated", "dv test 3", "dv test 4, untouched")); + + checkDatesPresent( + postMergeState, + List.of( + DateUtils.parseDate("2025-01-08"), + DateUtils.parseDate("2024-11-02"), + DateUtils.parseDate("2024-12-01"), + DateUtils.parseDate("2024-12-06"))); + } + + private void checkDatesPresent(List dataValues, List dates) { + assertTrue( + dataValues.stream() + .map(DataValue::getLastUpdated) + .collect(Collectors.toSet()) + .containsAll(dates), + "Expected dates should be present"); + } + + private void checkDataValuesPresent(List dataValues, List values) { + assertTrue( + dataValues.stream() + .map(DataValue::getValue) + .collect(Collectors.toSet()) + .containsAll(values), + "Expected DataValues should be present"); + } + + private void checkCocIdsPresent(List dataValues, List cocIds) { + assertTrue( + dataValues.stream() + .map(dv -> dv.getCategoryOptionCombo().getId()) + .collect(Collectors.toSet()) + .containsAll(cocIds), + "Data values have expected category option combos"); + } + + private void mergeDataValues(CategoryOptionCombo target, List sources) { + dataValueStore.mergeDataValuesWithCategoryOptionCombos(target, sources); + entityManager.flush(); + entityManager.clear(); + } + + private void addDataValues(DataValue... dvs) { + for (DataValue dv : dvs) dataValueStore.addDataValue(dv); + entityManager.flush(); + } + private DataValue createDataValue(char uniqueChar, Period period, String value) { DataElement dataElement = createDataElement(uniqueChar); dataElement.setValueType(ValueType.TEXT); diff --git a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/merge/category/CategoryOptionComboMergeServiceTest.java b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/merge/category/CategoryOptionComboMergeServiceTest.java new file mode 100644 index 000000000000..fce4dc7bee1d --- /dev/null +++ b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/merge/category/CategoryOptionComboMergeServiceTest.java @@ -0,0 +1,1820 @@ +/* + * Copyright (c) 2004-2024, 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: + * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 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. + * Neither the name of the HISP project 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.merge.category; + +import static org.hisp.dhis.dataapproval.DataApprovalAction.APPROVE; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import org.hisp.dhis.audit.AuditOperationType; +import org.hisp.dhis.category.Category; +import org.hisp.dhis.category.CategoryCombo; +import org.hisp.dhis.category.CategoryComboStore; +import org.hisp.dhis.category.CategoryOption; +import org.hisp.dhis.category.CategoryOptionCombo; +import org.hisp.dhis.category.CategoryOptionStore; +import org.hisp.dhis.category.CategoryService; +import org.hisp.dhis.common.BaseIdentifiableObject; +import org.hisp.dhis.common.CodeGenerator; +import org.hisp.dhis.common.IdentifiableObjectManager; +import org.hisp.dhis.common.UID; +import org.hisp.dhis.dataapproval.DataApproval; +import org.hisp.dhis.dataapproval.DataApprovalAudit; +import org.hisp.dhis.dataapproval.DataApprovalAuditQueryParams; +import org.hisp.dhis.dataapproval.DataApprovalAuditStore; +import org.hisp.dhis.dataapproval.DataApprovalLevel; +import org.hisp.dhis.dataapproval.DataApprovalStore; +import org.hisp.dhis.dataapproval.DataApprovalWorkflow; +import org.hisp.dhis.dataelement.DataElement; +import org.hisp.dhis.dataelement.DataElementOperand; +import org.hisp.dhis.dataelement.DataElementOperandStore; +import org.hisp.dhis.dataset.CompleteDataSetRegistration; +import org.hisp.dhis.dataset.CompleteDataSetRegistrationStore; +import org.hisp.dhis.dataset.DataSet; +import org.hisp.dhis.datavalue.DataValue; +import org.hisp.dhis.datavalue.DataValueAudit; +import org.hisp.dhis.datavalue.DataValueAuditQueryParams; +import org.hisp.dhis.datavalue.DataValueAuditStore; +import org.hisp.dhis.datavalue.DataValueStore; +import org.hisp.dhis.expression.Expression; +import org.hisp.dhis.feedback.ConflictException; +import org.hisp.dhis.feedback.MergeReport; +import org.hisp.dhis.merge.DataMergeStrategy; +import org.hisp.dhis.merge.MergeParams; +import org.hisp.dhis.merge.MergeService; +import org.hisp.dhis.minmax.MinMaxDataElement; +import org.hisp.dhis.minmax.MinMaxDataElementStore; +import org.hisp.dhis.organisationunit.OrganisationUnit; +import org.hisp.dhis.organisationunit.OrganisationUnitLevel; +import org.hisp.dhis.period.Period; +import org.hisp.dhis.period.PeriodService; +import org.hisp.dhis.period.PeriodType; +import org.hisp.dhis.period.PeriodTypeEnum; +import org.hisp.dhis.predictor.Predictor; +import org.hisp.dhis.predictor.PredictorStore; +import org.hisp.dhis.program.Enrollment; +import org.hisp.dhis.program.Event; +import org.hisp.dhis.program.EventStore; +import org.hisp.dhis.program.Program; +import org.hisp.dhis.program.ProgramStage; +import org.hisp.dhis.sms.command.SMSCommand; +import org.hisp.dhis.sms.command.code.SMSCode; +import org.hisp.dhis.sms.command.hibernate.SMSCommandStore; +import org.hisp.dhis.test.integration.PostgresIntegrationTestBase; +import org.hisp.dhis.trackedentity.TrackedEntity; +import org.hisp.dhis.util.DateUtils; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; + +/** + * All the tests in this class basically follow the same approach: + * + *

- Create metadata which have source CategoryOptionCombo references + * + *

- Perform a CategoryOptionCombo merge, passing a target CategoryOptionCombo + * + *

- Check that source CategoryOptionCombos have had their references removed/replaced with the + * target CategoryOptionCombo + */ +@Transactional +class CategoryOptionComboMergeServiceTest extends PostgresIntegrationTestBase { + + @Autowired private CategoryService categoryService; + @Autowired private CategoryOptionStore categoryOptionStore; + @Autowired private CategoryComboStore categoryComboStore; + @Autowired private DataElementOperandStore dataElementOperandStore; + @Autowired private MinMaxDataElementStore minMaxDataElementStore; + @Autowired private PredictorStore predictorStore; + @Autowired private SMSCommandStore smsCommandStore; + @Autowired private IdentifiableObjectManager manager; + @Autowired private MergeService categoryOptionComboMergeService; + @Autowired private PeriodService periodService; + @Autowired private DataValueStore dataValueStore; + @Autowired private CompleteDataSetRegistrationStore completeDataSetRegistrationStore; + @Autowired private DataValueAuditStore dataValueAuditStore; + @Autowired private DataApprovalAuditStore dataApprovalAuditStore; + @Autowired private DataApprovalStore dataApprovalStore; + @Autowired private EventStore eventStore; + + private CategoryCombo cc1; + private CategoryOptionCombo cocSource1; + private CategoryOptionCombo cocSource2; + private CategoryOptionCombo cocTarget; + private CategoryOptionCombo cocRandom; + private OrganisationUnit ou1; + private OrganisationUnit ou2; + private OrganisationUnit ou3; + private DataElement de1; + private DataElement de2; + private DataElement de3; + private Program program; + private Period p1; + private Period p2; + private Period p3; + + @BeforeEach + public void setUp() { + // 8 category options + CategoryOption co1A = createCategoryOption("1A", CodeGenerator.generateUid()); + CategoryOption co1B = createCategoryOption("1B", CodeGenerator.generateUid()); + CategoryOption co2A = createCategoryOption("2A", CodeGenerator.generateUid()); + CategoryOption co2B = createCategoryOption("2B", CodeGenerator.generateUid()); + CategoryOption co3A = createCategoryOption("3A", CodeGenerator.generateUid()); + CategoryOption co3B = createCategoryOption("3B", CodeGenerator.generateUid()); + CategoryOption co4A = createCategoryOption("4A", CodeGenerator.generateUid()); + CategoryOption co4B = createCategoryOption("4B", CodeGenerator.generateUid()); + categoryService.addCategoryOption(co1A); + categoryService.addCategoryOption(co1B); + categoryService.addCategoryOption(co2A); + categoryService.addCategoryOption(co2B); + categoryService.addCategoryOption(co3A); + categoryService.addCategoryOption(co3B); + categoryService.addCategoryOption(co4A); + categoryService.addCategoryOption(co4B); + + // 4 categories (each with 2 category options) + Category cat1 = createCategory('1', co1A, co1B); + Category cat2 = createCategory('2', co2A, co2B); + Category cat3 = createCategory('3', co3A, co3B); + Category cat4 = createCategory('4', co4A, co4B); + categoryService.addCategory(cat1); + categoryService.addCategory(cat2); + categoryService.addCategory(cat3); + categoryService.addCategory(cat4); + + cc1 = createCategoryCombo('1', cat1, cat2); + CategoryCombo cc2 = createCategoryCombo('2', cat3, cat4); + categoryService.addCategoryCombo(cc1); + categoryService.addCategoryCombo(cc2); + + categoryService.generateOptionCombos(cc1); + categoryService.generateOptionCombos(cc2); + + cocSource1 = getCocWithOptions("1A", "2A"); + cocSource2 = getCocWithOptions("1B", "2B"); + cocTarget = getCocWithOptions("3A", "4B"); + cocRandom = getCocWithOptions("3B", "4A"); + + ou1 = createOrganisationUnit('A'); + ou2 = createOrganisationUnit('B'); + ou3 = createOrganisationUnit('C'); + manager.save(List.of(ou1, ou2, ou3)); + + de1 = createDataElement('1'); + de2 = createDataElement('2'); + de3 = createDataElement('3'); + manager.save(List.of(de1, de2, de3)); + + program = createProgram('q'); + manager.save(program); + + p1 = createPeriod(DateUtils.parseDate("2024-1-4"), DateUtils.parseDate("2024-1-4")); + p1.setPeriodType(PeriodType.getPeriodType(PeriodTypeEnum.MONTHLY)); + p2 = createPeriod(DateUtils.parseDate("2024-2-4"), DateUtils.parseDate("2024-2-4")); + p2.setPeriodType(PeriodType.getPeriodType(PeriodTypeEnum.MONTHLY)); + p3 = createPeriod(DateUtils.parseDate("2024-3-4"), DateUtils.parseDate("2024-3-4")); + p3.setPeriodType(PeriodType.getPeriodType(PeriodTypeEnum.MONTHLY)); + periodService.addPeriod(p1); + periodService.addPeriod(p2); + periodService.addPeriod(p3); + } + + // ----------------------------- + // ------ CategoryOption ------- + // ----------------------------- + @Test + @DisplayName("CategoryOption refs to source CategoryOptionCombos are replaced, sources deleted") + void categoryOptionRefsReplacedSourcesDeletedTest() throws ConflictException { + // given category option combo state before merge + List allCategoryOptionCombos = + categoryService.getAllCategoryOptionCombos(); + List allCategoryOptions = categoryService.getAllCategoryOptions(); + + assertEquals(9, allCategoryOptionCombos.size(), "9 COCs including 1 default"); + assertEquals(9, allCategoryOptions.size(), "9 COs including 1 default"); + + List coSourcesBefore = + categoryOptionStore.getByCategoryOptionCombo( + List.of(UID.of(cocSource1.getUid()), UID.of(cocSource2.getUid()))); + List coTargetBefore = + categoryOptionStore.getByCategoryOptionCombo(List.of(UID.of(cocTarget.getUid()))); + + assertEquals( + 4, + coSourcesBefore.size(), + "Expect 4 category options with source category option combo refs"); + assertEquals( + 2, + coTargetBefore.size(), + "Expect 2 category options with target category option combo refs"); + + // when + MergeParams mergeParams = getMergeParams(); + MergeReport report = categoryOptionComboMergeService.processMerge(mergeParams); + + // then + List coSourcesAfter = + categoryOptionStore.getByCategoryOptionCombo( + List.of(UID.of(cocSource1), UID.of(cocSource2))); + List coTargetAfter = + categoryOptionStore.getByCategoryOptionCombo(List.of(UID.of(cocTarget))); + + assertFalse(report.hasErrorMessages()); + assertEquals( + 0, coSourcesAfter.size(), "Expect 0 entries with source category option combo refs"); + assertEquals( + 6, coTargetAfter.size(), "Expect 6 entries with target category option combo refs"); + + assertTrue( + categoryService.getCategoryOptionCombosByUid(UID.of(cocSource1, cocSource2)).isEmpty(), + "There should be no source COCs after deletion during merge"); + } + + // ----------------------------- + // ------ CategoryCombo ------- + // ----------------------------- + @Test + @DisplayName("CategoryCombo refs to source CategoryOptionCombos are replaced, sources deleted") + void categoryComboRefsReplacedSourcesDeletedTest() throws ConflictException { + // given category option combo state before merge + List allCategoryOptionCombos = + categoryService.getAllCategoryOptionCombos(); + List allCategoryCombos = categoryService.getAllCategoryCombos(); + + assertEquals(9, allCategoryOptionCombos.size(), "9 COCs including 1 default"); + assertEquals(3, allCategoryCombos.size(), "3 CCs including 1 default"); + + List ccSourcesBefore = + categoryComboStore.getByCategoryOptionCombo(UID.of(cocSource1, cocSource2)); + List ccTargetBefore = + categoryComboStore.getByCategoryOptionCombo(Set.of(UID.of(cocTarget.getUid()))); + + assertEquals( + 1, + ccSourcesBefore.size(), + "Expect 1 category combo with source category option combo refs"); + assertEquals( + 1, ccTargetBefore.size(), "Expect 1 category combo with target category option combo refs"); + assertEquals( + 4, + ccTargetBefore.get(0).getOptionCombos().size(), + "Expect 4 COCs with target category combo"); + + // when + MergeParams mergeParams = getMergeParams(); + MergeReport report = categoryOptionComboMergeService.processMerge(mergeParams); + + List allCOCsAfter = categoryService.getAllCategoryOptionCombos(); + List allCCsAfter = categoryService.getAllCategoryCombos(); + + assertEquals(7, allCOCsAfter.size(), "7 COCs including 1 default"); + assertEquals(3, allCCsAfter.size(), "3 CCs including 1 default"); + + // then + List ccSourcesAfter = + categoryComboStore.getByCategoryOptionCombo(UID.of(cocSource1, cocSource2)); + List ccTargetAfter = + categoryComboStore.getByCategoryOptionCombo(Set.of(UID.of(cocTarget))); + CategoryCombo catCombo1 = categoryComboStore.getByUid(cc1.getUid()); + + assertFalse(report.hasErrorMessages()); + assertEquals( + 0, ccSourcesAfter.size(), "Expect 0 entries with source category option combo refs"); + assertEquals( + 1, ccTargetAfter.size(), "Expect 2 entries with target category option combo refs"); + assertEquals(5, catCombo1.getOptionCombos().size(), "Expect 5 COCs for CC1"); + assertEquals( + 4, + ccTargetAfter.get(0).getOptionCombos().size(), + "Expect 4 COCs with target category combo"); + + assertTrue( + categoryService.getCategoryOptionCombosByUid(UID.of(cocSource1, cocSource2)).isEmpty(), + "There should be no source COCs after deletion during merge"); + } + + // ----------------------------- + // ---- DataElementOperand ----- + // ----------------------------- + @Test + @DisplayName( + "DataElementOperand refs to source CategoryOptionCombos are replaced, sources deleted") + void dataElementOperandRefsReplacedSourcesDeletedTest() throws ConflictException { + DataElementOperand deo1 = new DataElementOperand(); + deo1.setDataElement(de1); + deo1.setCategoryOptionCombo(cocSource1); + + DataElementOperand deo2 = new DataElementOperand(); + deo2.setDataElement(de2); + deo2.setCategoryOptionCombo(cocSource2); + + DataElementOperand deo3 = new DataElementOperand(); + deo3.setDataElement(de3); + deo3.setCategoryOptionCombo(cocTarget); + + manager.save(List.of(de1, de2, de3)); + manager.save(List.of(deo1, deo2, deo3)); + + // given state before merge + List allCategoryOptionCombos = + categoryService.getAllCategoryOptionCombos(); + + assertEquals(9, allCategoryOptionCombos.size(), "9 COCs including 1 default"); + + List deoSourcesBefore = + dataElementOperandStore.getByCategoryOptionCombo(UID.of(cocSource1, cocSource2)); + List deoTargetBefore = + dataElementOperandStore.getByCategoryOptionCombo(Set.of(UID.of(cocTarget.getUid()))); + + assertEquals( + 2, + deoSourcesBefore.size(), + "Expect 2 data element operands with source category option combo refs"); + assertEquals( + 1, + deoTargetBefore.size(), + "Expect 1 data element operand with target category option combo refs"); + + // when + MergeParams mergeParams = getMergeParams(); + MergeReport report = categoryOptionComboMergeService.processMerge(mergeParams); + + List allCOCsAfter = categoryService.getAllCategoryOptionCombos(); + + // then + assertEquals(7, allCOCsAfter.size(), "7 COCs including 1 default"); + assertFalse(report.hasErrorMessages(), "there should be no merge errors"); + List deoSourcesAfter = + dataElementOperandStore.getByCategoryOptionCombo(UID.of(cocSource1, cocSource2)); + List deoTargetAfter = + dataElementOperandStore.getByCategoryOptionCombo(Set.of(UID.of(cocTarget.getUid()))); + assertEquals( + 0, deoSourcesAfter.size(), "Expect 0 entries with source category option combo refs"); + assertEquals( + 3, deoTargetAfter.size(), "Expect 3 entries with target category option combo refs"); + } + + // ----------------------------- + // ---- MinMaxDataElement ----- + // ----------------------------- + @Test + @DisplayName( + "MinMaxDataElement refs to source CategoryOptionCombos are replaced, sources deleted") + void minMaxDataElementRefsReplacedSourcesDeletedTest() throws ConflictException { + OrganisationUnit ou1 = createOrganisationUnit('1'); + OrganisationUnit ou2 = createOrganisationUnit('2'); + OrganisationUnit ou3 = createOrganisationUnit('3'); + manager.save(List.of(ou1, ou2, ou3)); + + MinMaxDataElement mmde1 = new MinMaxDataElement(de1, ou1, cocSource1, 0, 100, false); + MinMaxDataElement mmde2 = new MinMaxDataElement(de2, ou2, cocSource2, 0, 100, false); + MinMaxDataElement mmde3 = new MinMaxDataElement(de3, ou3, cocTarget, 0, 100, false); + minMaxDataElementStore.save(mmde1); + minMaxDataElementStore.save(mmde2); + minMaxDataElementStore.save(mmde3); + + // given state before merge + List allCategoryOptionCombos = + categoryService.getAllCategoryOptionCombos(); + + assertEquals(9, allCategoryOptionCombos.size(), "9 COCs including 1 default"); + + List mmdeSourcesBefore = + minMaxDataElementStore.getByCategoryOptionCombo(UID.of(cocSource1, cocSource2)); + List mmdeTargetBefore = + minMaxDataElementStore.getByCategoryOptionCombo(Set.of(UID.of(cocTarget.getUid()))); + + assertEquals( + 2, + mmdeSourcesBefore.size(), + "Expect 2 min max data elements with source category option combo refs"); + assertEquals( + 1, + mmdeTargetBefore.size(), + "Expect 1 min max data element with target category option combo refs"); + + // when + MergeParams mergeParams = getMergeParams(); + MergeReport report = categoryOptionComboMergeService.processMerge(mergeParams); + + List allCOCsAfter = categoryService.getAllCategoryOptionCombos(); + + assertEquals(7, allCOCsAfter.size(), "7 COCs including 1 default"); + + // then + List mmdeSourcesAfter = + minMaxDataElementStore.getByCategoryOptionCombo(UID.of(cocSource1, cocSource2)); + List mmdeTargetAfter = + minMaxDataElementStore.getByCategoryOptionCombo(Set.of(UID.of(cocTarget.getUid()))); + + assertFalse(report.hasErrorMessages()); + assertEquals( + 0, mmdeSourcesAfter.size(), "Expect 0 entries with source category option combo refs"); + assertEquals( + 3, mmdeTargetAfter.size(), "Expect 3 entries with target category option combo refs"); + } + + // ---------------------- + // ---- Predictor ----- + // ---------------------- + @Test + @DisplayName("Predictor refs to source CategoryOptionCombos are replaced, sources deleted") + void predictorRefsReplacedSourcesDeletedTest() throws ConflictException { + OrganisationUnitLevel ouLevel = new OrganisationUnitLevel(1, "Level 1"); + manager.save(ouLevel); + + Expression exp1 = new Expression("#{uid00001}", de1.getUid()); + Expression exp2 = new Expression("#{uid00002}", de2.getUid()); + Expression exp3 = new Expression("#{uid00003}", de3.getUid()); + + Predictor p1 = + createPredictor( + de1, + cocSource1, + "1", + exp1, + exp1, + PeriodType.getPeriodTypeByName("Monthly"), + ouLevel, + 0, + 1, + 1); + + Predictor p2 = + createPredictor( + de2, + cocSource2, + "2", + exp2, + exp2, + PeriodType.getPeriodTypeByName("Monthly"), + ouLevel, + 0, + 0, + 0); + + Predictor p3 = + createPredictor( + de3, + cocTarget, + "3", + exp3, + exp3, + PeriodType.getPeriodTypeByName("Monthly"), + ouLevel, + 1, + 3, + 2); + + manager.save(List.of(p1, p2, p3)); + + // given state before merge + List allCategoryOptionCombos = + categoryService.getAllCategoryOptionCombos(); + + assertEquals(9, allCategoryOptionCombos.size(), "9 COCs including 1 default"); + + List pSourcesBefore = + predictorStore.getByCategoryOptionCombo(UID.of(cocSource1, cocSource2)); + List pTargetBefore = + predictorStore.getByCategoryOptionCombo(Set.of(UID.of(cocTarget.getUid()))); + + assertEquals( + 2, pSourcesBefore.size(), "Expect 2 predictors with source category option combo refs"); + assertEquals( + 1, pTargetBefore.size(), "Expect 1 predictor with target category option combo refs"); + + // when + MergeParams mergeParams = getMergeParams(); + MergeReport report = categoryOptionComboMergeService.processMerge(mergeParams); + + List allCOCsAfter = categoryService.getAllCategoryOptionCombos(); + + assertEquals(7, allCOCsAfter.size(), "7 COCs including 1 default"); + + // then + List pSourcesAfter = + predictorStore.getByCategoryOptionCombo(UID.of(cocSource1, cocSource2)); + List pTargetAfter = + predictorStore.getByCategoryOptionCombo(Set.of(UID.of(cocTarget.getUid()))); + + assertFalse(report.hasErrorMessages()); + assertEquals( + 0, pSourcesAfter.size(), "Expect 0 entries with source category option combo refs"); + assertEquals(3, pTargetAfter.size(), "Expect 3 entries with target category option combo refs"); + } + + // -------------------- + // ---- SMSCode ----- + // -------------------- + @Test + @DisplayName("SMSCode refs to source CategoryOptionCombos are replaced, sources deleted") + void smsCodeRefsReplacedSourcesDeletedTest() throws ConflictException { + SMSCode smsCode1 = new SMSCode(); + smsCode1.setDataElement(de1); + smsCode1.setOptionId(cocSource1); + + SMSCode smsCode2 = new SMSCode(); + smsCode2.setDataElement(de2); + smsCode2.setOptionId(cocSource2); + + SMSCode smsCode3 = new SMSCode(); + smsCode3.setDataElement(de3); + smsCode3.setOptionId(cocTarget); + + SMSCommand smsCommand = new SMSCommand(); + smsCommand.setCodes(Set.of(smsCode1, smsCode2, smsCode3)); + + smsCommandStore.save(smsCommand); + + // given state before merge + List allCategoryOptionCombos = + categoryService.getAllCategoryOptionCombos(); + + assertEquals(9, allCategoryOptionCombos.size(), "9 COCs including 1 default"); + + List cSourcesBefore = + smsCommandStore.getCodesByCategoryOptionCombo(UID.of(cocSource1, cocSource2)); + List cTargetBefore = + smsCommandStore.getCodesByCategoryOptionCombo(Set.of(UID.of(cocTarget.getUid()))); + + assertEquals(2, cSourcesBefore.size(), "Expect 2 code with source category option combo refs"); + assertEquals(1, cTargetBefore.size(), "Expect 1 code with target category option combo refs"); + + // when + MergeParams mergeParams = getMergeParams(); + MergeReport report = categoryOptionComboMergeService.processMerge(mergeParams); + + List allCOCsAfter = categoryService.getAllCategoryOptionCombos(); + + assertEquals(7, allCOCsAfter.size(), "7 COCs including 1 default"); + + // then + List cSourcesAfter = + smsCommandStore.getCodesByCategoryOptionCombo(UID.of(cocSource1, cocSource2)); + List cTargetAfter = + smsCommandStore.getCodesByCategoryOptionCombo(Set.of(UID.of(cocTarget.getUid()))); + + assertFalse(report.hasErrorMessages()); + assertEquals( + 0, cSourcesAfter.size(), "Expect 0 entries with source category option combo refs"); + assertEquals(3, cTargetAfter.size(), "Expect 3 entries with target category option combo refs"); + } + + // ------------------------------------- + // -- DataValue Category Option Combo -- + // ------------------------------------- + @Test + @DisplayName( + "Non-duplicate DataValues with references to source COCs are replaced with target COC using LAST_UPDATED strategy") + void dataValueMergeCocLastUpdatedTest() throws ConflictException { + // given + DataValue dv1 = createDataValue(de1, p1, ou1, cocSource1, cocRandom, "value1"); + DataValue dv2 = createDataValue(de2, p2, ou1, cocSource2, cocRandom, "value2"); + DataValue dv3 = createDataValue(de3, p3, ou1, cocTarget, cocRandom, "value3"); + + dataValueStore.addDataValue(dv1); + dataValueStore.addDataValue(dv2); + dataValueStore.addDataValue(dv3); + + // params + MergeParams mergeParams = getMergeParams(); + mergeParams.setDataMergeStrategy(DataMergeStrategy.LAST_UPDATED); + + // when + MergeReport report = categoryOptionComboMergeService.processMerge(mergeParams); + + // then + List sourceItems = + dataValueStore.getAllDataValuesByCatOptCombo(UID.of(cocSource1, cocSource2)); + List targetItems = + dataValueStore.getAllDataValuesByCatOptCombo(List.of(UID.of(cocTarget))); + + List allCategoryOptionCombos = + categoryService.getAllCategoryOptionCombos(); + + assertFalse(report.hasErrorMessages()); + assertEquals(0, sourceItems.size(), "Expect 0 entries with source COC refs"); + assertEquals(3, targetItems.size(), "Expect 3 entries with target COC refs"); + assertEquals(7, allCategoryOptionCombos.size(), "Expect 7 COCs present"); + assertTrue(allCategoryOptionCombos.contains(cocTarget), "Target COC should be present"); + assertFalse(allCategoryOptionCombos.contains(cocSource1), "Source COC should not be present"); + assertFalse(allCategoryOptionCombos.contains(cocSource2), "Source COC should not be present"); + } + + @Test + @DisplayName("DataValues with references to source COCs are deleted using DISCARD strategy") + void dataValueMergeCocDiscardTest() throws ConflictException { + // given + DataValue dv1 = createDataValue(de1, p1, ou1, cocSource1, cocRandom, "value1"); + DataValue dv2 = createDataValue(de2, p2, ou1, cocSource2, cocRandom, "value2"); + DataValue dv3 = createDataValue(de3, p3, ou1, cocTarget, cocRandom, "value3"); + + dataValueStore.addDataValue(dv1); + dataValueStore.addDataValue(dv2); + dataValueStore.addDataValue(dv3); + + // params + MergeParams mergeParams = getMergeParams(); + mergeParams.setDataMergeStrategy(DataMergeStrategy.DISCARD); + + // when + MergeReport report = categoryOptionComboMergeService.processMerge(mergeParams); + + // then + List sourceItems = + dataValueStore.getAllDataValuesByCatOptCombo(UID.of(cocSource1, cocSource2)); + List targetItems = + dataValueStore.getAllDataValuesByCatOptCombo(List.of(UID.of(cocTarget))); + + List allCategoryOptionCombos = + categoryService.getAllCategoryOptionCombos(); + + assertFalse(report.hasErrorMessages()); + assertEquals(0, sourceItems.size(), "Expect 0 entries with source COC refs"); + assertEquals(1, targetItems.size(), "Expect 1 entry with target COC ref only"); + assertEquals(7, allCategoryOptionCombos.size(), "Expect 7 COCs present"); + assertTrue(allCategoryOptionCombos.contains(cocTarget), "Target COC should be present"); + assertFalse(allCategoryOptionCombos.contains(cocSource1), "Source COC should not be present"); + assertFalse(allCategoryOptionCombos.contains(cocSource2), "Source COC should not be present"); + } + + // -------------------------------------- + // -- DataValue Attribute Option Combo -- + // -------------------------------------- + @Test + @DisplayName( + "Non-duplicate DataValues with references to source AOCs are replaced with target AOC using LAST_UPDATED strategy") + void dataValueMergeAocLastUpdatedTest() throws ConflictException { + // given + DataValue dv1 = createDataValue(de1, p1, ou1, cocRandom, cocSource1, "value1"); + DataValue dv2 = createDataValue(de2, p2, ou1, cocRandom, cocSource2, "value2"); + DataValue dv3 = createDataValue(de3, p3, ou1, cocRandom, cocTarget, "value3"); + + dataValueStore.addDataValue(dv1); + dataValueStore.addDataValue(dv2); + dataValueStore.addDataValue(dv3); + + // params + MergeParams mergeParams = getMergeParams(); + mergeParams.setDataMergeStrategy(DataMergeStrategy.LAST_UPDATED); + + // when + MergeReport report = categoryOptionComboMergeService.processMerge(mergeParams); + + // then + List sourceItems = + dataValueStore.getAllDataValuesByAttrOptCombo(UID.of(cocSource1, cocSource2)); + List targetItems = + dataValueStore.getAllDataValuesByAttrOptCombo(List.of(UID.of(cocTarget))); + + List allCategoryOptionCombos = + categoryService.getAllCategoryOptionCombos(); + + assertFalse(report.hasErrorMessages()); + assertEquals(0, sourceItems.size(), "Expect 0 entries with source COC refs"); + assertEquals(3, targetItems.size(), "Expect 3 entries with target COC refs"); + assertEquals(7, allCategoryOptionCombos.size(), "Expect 7 COCs present"); + assertTrue(allCategoryOptionCombos.contains(cocTarget), "Target COC should be present"); + assertFalse(allCategoryOptionCombos.contains(cocSource1), "Source COC should not be present"); + assertFalse(allCategoryOptionCombos.contains(cocSource2), "Source COC should not be present"); + } + + @Test + @DisplayName("DataValues with references to source AOCs are deleted, using DISCARD strategy") + void dataValueMergeAocDiscardTest() throws ConflictException { + // given + DataValue dv1 = createDataValue(de1, p1, ou1, cocRandom, cocSource1, "value1"); + DataValue dv2 = createDataValue(de2, p2, ou1, cocRandom, cocSource2, "value2"); + DataValue dv3 = createDataValue(de3, p3, ou1, cocRandom, cocTarget, "value3"); + + dataValueStore.addDataValue(dv1); + dataValueStore.addDataValue(dv2); + dataValueStore.addDataValue(dv3); + + // params + MergeParams mergeParams = getMergeParams(); + mergeParams.setDataMergeStrategy(DataMergeStrategy.DISCARD); + + // when + MergeReport report = categoryOptionComboMergeService.processMerge(mergeParams); + + // then + List sourceItems = + dataValueStore.getAllDataValuesByAttrOptCombo(UID.of(cocSource1, cocSource2)); + List targetItems = + dataValueStore.getAllDataValuesByAttrOptCombo(List.of(UID.of(cocTarget))); + + List allCategoryOptionCombos = + categoryService.getAllCategoryOptionCombos(); + + assertFalse(report.hasErrorMessages()); + assertEquals(0, sourceItems.size(), "Expect 0 entries with source COC refs"); + assertEquals(1, targetItems.size(), "Expect 1 entry with target COC ref only"); + assertEquals(7, allCategoryOptionCombos.size(), "Expect 7 COCs present"); + assertTrue(allCategoryOptionCombos.contains(cocTarget), "Target COC should be present"); + assertFalse(allCategoryOptionCombos.contains(cocSource1), "Source COC should not be present"); + assertFalse(allCategoryOptionCombos.contains(cocSource2), "Source COC should not be present"); + } + + // ------------------------ + // -- DataValueAudit -- + // ------------------------ + @Test + @DisplayName( + "DataValueAudits with references to source COCs are not changed or deleted when sources not deleted") + void dataValueAuditMergeTest() throws ConflictException { + // given + DataValueAudit dva1 = createDataValueAudit(cocSource1, "1", p1); + DataValueAudit dva2 = createDataValueAudit(cocSource1, "2", p1); + DataValueAudit dva3 = createDataValueAudit(cocSource2, "1", p1); + DataValueAudit dva4 = createDataValueAudit(cocSource2, "2", p1); + DataValueAudit dva5 = createDataValueAudit(cocTarget, "1", p1); + + dataValueAuditStore.addDataValueAudit(dva1); + dataValueAuditStore.addDataValueAudit(dva2); + dataValueAuditStore.addDataValueAudit(dva3); + dataValueAuditStore.addDataValueAudit(dva4); + dataValueAuditStore.addDataValueAudit(dva5); + + // params + MergeParams mergeParams = getMergeParams(); + mergeParams.setDeleteSources(false); + + // when + MergeReport report = categoryOptionComboMergeService.processMerge(mergeParams); + + // then + DataValueAuditQueryParams source1DvaQueryParams = getQueryParams(cocSource1); + DataValueAuditQueryParams source2DvaQueryParams = getQueryParams(cocSource2); + DataValueAuditQueryParams targetDvaQueryParams = getQueryParams(cocTarget); + + List source1Audits = + dataValueAuditStore.getDataValueAudits(source1DvaQueryParams); + List source2Audits = + dataValueAuditStore.getDataValueAudits(source2DvaQueryParams); + + List targetItems = dataValueAuditStore.getDataValueAudits(targetDvaQueryParams); + + assertFalse(report.hasErrorMessages()); + assertEquals( + 4, source1Audits.size() + source2Audits.size(), "Expect 4 entries with source COC refs"); + assertEquals(1, targetItems.size(), "Expect 1 entry with target COC ref"); + } + + @Test + @DisplayName( + "DataValueAudits with references to source COCs are deleted when sources are deleted") + void dataValueAuditMergeDeleteTest() throws ConflictException { + // given + DataValueAudit dva1 = createDataValueAudit(cocSource1, "1", p1); + DataValueAudit dva2 = createDataValueAudit(cocSource1, "2", p1); + DataValueAudit dva3 = createDataValueAudit(cocSource2, "1", p1); + DataValueAudit dva4 = createDataValueAudit(cocSource2, "2", p1); + DataValueAudit dva5 = createDataValueAudit(cocTarget, "1", p1); + + dataValueAuditStore.addDataValueAudit(dva1); + dataValueAuditStore.addDataValueAudit(dva2); + dataValueAuditStore.addDataValueAudit(dva3); + dataValueAuditStore.addDataValueAudit(dva4); + dataValueAuditStore.addDataValueAudit(dva5); + + // params + MergeParams mergeParams = getMergeParams(); + + // when + MergeReport report = categoryOptionComboMergeService.processMerge(mergeParams); + + // then + DataValueAuditQueryParams source1DvaQueryParams = getQueryParams(cocSource1); + DataValueAuditQueryParams source2DvaQueryParams = getQueryParams(cocSource2); + DataValueAuditQueryParams targetDvaQueryParams = getQueryParams(cocTarget); + + List source1Audits = + dataValueAuditStore.getDataValueAudits(source1DvaQueryParams); + List source2Audits = + dataValueAuditStore.getDataValueAudits(source2DvaQueryParams); + + List targetItems = dataValueAuditStore.getDataValueAudits(targetDvaQueryParams); + + assertFalse(report.hasErrorMessages()); + assertEquals( + 0, source1Audits.size() + source2Audits.size(), "Expect 0 entries with source COC refs"); + assertEquals(1, targetItems.size(), "Expect 1 entry with target COC ref"); + } + + // ------------------------ + // -- DataApprovalAudit -- + // ------------------------ + @Test + @DisplayName( + "DataApprovalAudits with references to source COCs are not changed or deleted when sources not deleted") + void dataApprovalAuditMergeTest() throws ConflictException { + // given + DataApprovalLevel level1 = new DataApprovalLevel(); + level1.setLevel(1); + level1.setName("DAL1"); + manager.save(level1); + + DataApprovalLevel level2 = new DataApprovalLevel(); + level2.setLevel(2); + level2.setName("DAL2"); + manager.save(level2); + + DataApprovalWorkflow daw = new DataApprovalWorkflow(); + daw.setPeriodType(PeriodType.getPeriodType(PeriodTypeEnum.MONTHLY)); + daw.setName("DAW"); + daw.setCategoryCombo(cc1); + manager.save(daw); + + DataApprovalAudit daa1 = createDataApprovalAudit(cocSource1, level1, daw, p1); + DataApprovalAudit daa2 = createDataApprovalAudit(cocSource1, level2, daw, p2); + DataApprovalAudit daa3 = createDataApprovalAudit(cocSource2, level1, daw, p1); + DataApprovalAudit daa4 = createDataApprovalAudit(cocSource2, level2, daw, p2); + DataApprovalAudit daa5 = createDataApprovalAudit(cocTarget, level1, daw, p1); + + dataApprovalAuditStore.save(daa1); + dataApprovalAuditStore.save(daa2); + dataApprovalAuditStore.save(daa3); + dataApprovalAuditStore.save(daa4); + dataApprovalAuditStore.save(daa5); + + // params + MergeParams mergeParams = getMergeParams(); + mergeParams.setDeleteSources(false); + + // when + MergeReport report = categoryOptionComboMergeService.processMerge(mergeParams); + + // then + DataApprovalAuditQueryParams targetDaaQueryParams = + new DataApprovalAuditQueryParams() + .setAttributeOptionCombos(new HashSet<>(Collections.singletonList(cocTarget))) + .setLevels(Set.of(level1)); + + List sourceAudits = dataApprovalAuditStore.getAll(); + List targetItems = + dataApprovalAuditStore.getDataApprovalAudits(targetDaaQueryParams); + + assertFalse(report.hasErrorMessages()); + assertEquals(5, sourceAudits.size(), "Expect 4 entries with source COC refs"); + assertEquals(1, targetItems.size(), "Expect 1 entry with target COC ref"); + } + + @Test + @DisplayName( + "DataApprovalAudits with references to source COCs are deleted when sources are deleted") + void dataApprovalAuditMergeDeleteTest() throws ConflictException { + // given + DataApprovalLevel dataApprovalLevel = new DataApprovalLevel(); + dataApprovalLevel.setLevel(1); + dataApprovalLevel.setName("DAL"); + manager.save(dataApprovalLevel); + + DataApprovalWorkflow daw = new DataApprovalWorkflow(); + daw.setPeriodType(PeriodType.getPeriodType(PeriodTypeEnum.MONTHLY)); + daw.setName("DAW"); + daw.setCategoryCombo(cc1); + manager.save(daw); + + DataApprovalAudit daa1 = createDataApprovalAudit(cocSource1, dataApprovalLevel, daw, p1); + DataApprovalAudit daa2 = createDataApprovalAudit(cocSource1, dataApprovalLevel, daw, p1); + DataApprovalAudit daa3 = createDataApprovalAudit(cocSource2, dataApprovalLevel, daw, p1); + DataApprovalAudit daa4 = createDataApprovalAudit(cocSource2, dataApprovalLevel, daw, p1); + DataApprovalAudit daa5 = createDataApprovalAudit(cocTarget, dataApprovalLevel, daw, p1); + + dataApprovalAuditStore.save(daa1); + dataApprovalAuditStore.save(daa2); + dataApprovalAuditStore.save(daa3); + dataApprovalAuditStore.save(daa4); + dataApprovalAuditStore.save(daa5); + + // params + MergeParams mergeParams = getMergeParams(); + + // when + MergeReport report = categoryOptionComboMergeService.processMerge(mergeParams); + + // then + DataApprovalAuditQueryParams source1DaaQueryParams = + new DataApprovalAuditQueryParams() + .setAttributeOptionCombos(new HashSet<>(Arrays.asList(cocSource1, cocSource2))); + DataApprovalAuditQueryParams targetDaaQueryParams = + new DataApprovalAuditQueryParams() + .setAttributeOptionCombos(new HashSet<>(Collections.singletonList(cocTarget))); + + List sourceAudits = + dataApprovalAuditStore.getDataApprovalAudits(source1DaaQueryParams); + List targetItems = + dataApprovalAuditStore.getDataApprovalAudits(targetDaaQueryParams); + + assertFalse(report.hasErrorMessages()); + assertEquals(0, sourceAudits.size(), "Expect 0 entries with source COC refs"); + assertEquals(1, targetItems.size(), "Expect 1 entry with target COC ref"); + } + + // ----------------------- + // ---- DataApproval ---- + // ----------------------- + @Test + @DisplayName( + "Non-duplicate DataApprovals with references to source COCs are replaced with target COC using LAST_UPDATED strategy") + void dataApprovalMergeCocLastUpdatedTest() throws ConflictException { + // given + DataApprovalLevel level1 = new DataApprovalLevel(); + level1.setLevel(1); + level1.setName("DAL1"); + manager.save(level1); + + DataApprovalLevel level2 = new DataApprovalLevel(); + level2.setLevel(2); + level2.setName("DAL2"); + manager.save(level2); + + DataApprovalWorkflow daw1 = new DataApprovalWorkflow(); + daw1.setPeriodType(PeriodType.getPeriodType(PeriodTypeEnum.MONTHLY)); + daw1.setName("DAW1"); + daw1.setCategoryCombo(cc1); + manager.save(daw1); + + DataApprovalWorkflow daw2 = new DataApprovalWorkflow(); + daw2.setPeriodType(PeriodType.getPeriodType(PeriodTypeEnum.MONTHLY)); + daw2.setName("DAW2"); + daw2.setCategoryCombo(cc1); + manager.save(daw2); + + DataApproval da1 = createDataApproval(cocSource1, level1, daw1, p1, ou1); + DataApproval da2 = createDataApproval(cocSource2, level2, daw1, p2, ou1); + DataApproval da3 = createDataApproval(cocTarget, level2, daw2, p2, ou2); + DataApproval da4 = createDataApproval(cocRandom, level2, daw2, p3, ou3); + + dataApprovalStore.addDataApproval(da1); + dataApprovalStore.addDataApproval(da2); + dataApprovalStore.addDataApproval(da3); + dataApprovalStore.addDataApproval(da4); + + // pre-merge state + List sourcesPreMerge = + dataApprovalStore.getByCategoryOptionCombo(UID.of(cocSource1, cocSource2)); + List targetPreMerge = + dataApprovalStore.getByCategoryOptionCombo(List.of(UID.of(cocTarget))); + assertEquals(2, sourcesPreMerge.size(), "Expect 2 entries with source COC refs"); + assertEquals(1, targetPreMerge.size(), "Expect 1 entries with target COC refs"); + + // params + MergeParams mergeParams = getMergeParams(); + mergeParams.setDataMergeStrategy(DataMergeStrategy.LAST_UPDATED); + + // when + MergeReport report = categoryOptionComboMergeService.processMerge(mergeParams); + + // then + List sourceItems = + dataApprovalStore.getByCategoryOptionCombo(UID.of(cocSource1, cocSource2)); + List targetItems = + dataApprovalStore.getByCategoryOptionCombo(List.of(UID.of(cocTarget))); + + List allCategoryOptionCombos = + categoryService.getAllCategoryOptionCombos(); + + assertFalse(report.hasErrorMessages()); + assertEquals(0, sourceItems.size(), "Expect 0 entries with source COC refs"); + assertEquals(3, targetItems.size(), "Expect 3 entries with target COC refs"); + assertEquals(7, allCategoryOptionCombos.size(), "Expect 7 COCs present"); + assertTrue(allCategoryOptionCombos.contains(cocTarget), "Target COC should be present"); + assertFalse(allCategoryOptionCombos.contains(cocSource1), "Source COC should not be present"); + assertFalse(allCategoryOptionCombos.contains(cocSource2), "Source COC should not be present"); + } + + @Test + @DisplayName( + "Duplicate DataApprovals are replaced with target COC using LAST_UPDATED strategy, target has latest lastUpdated value") + void duplicateDataApprovalMergeCocLastUpdatedTest() throws ConflictException { + // given + DataApprovalLevel level1 = new DataApprovalLevel(); + level1.setLevel(1); + level1.setName("DAL1"); + manager.save(level1); + + DataApprovalLevel level2 = new DataApprovalLevel(); + level2.setLevel(2); + level2.setName("DAL2"); + manager.save(level2); + + DataApprovalWorkflow daw1 = new DataApprovalWorkflow(); + daw1.setPeriodType(PeriodType.getPeriodType(PeriodTypeEnum.MONTHLY)); + daw1.setName("DAW1"); + daw1.setCategoryCombo(cc1); + manager.save(daw1); + + DataApprovalWorkflow daw2 = new DataApprovalWorkflow(); + daw2.setPeriodType(PeriodType.getPeriodType(PeriodTypeEnum.MONTHLY)); + daw2.setName("DAW2"); + daw2.setCategoryCombo(cc1); + manager.save(daw2); + + DataApproval da1a = createDataApproval(cocSource1, level1, daw1, p1, ou1); + da1a.setLastUpdated(DateUtils.parseDate("2024-6-8")); + DataApproval da1b = createDataApproval(cocSource1, level1, daw1, p2, ou1); + da1b.setLastUpdated(DateUtils.parseDate("2024-10-8")); + DataApproval da2a = createDataApproval(cocSource2, level1, daw1, p1, ou1); + da2a.setLastUpdated(DateUtils.parseDate("2024-6-8")); + DataApproval da2b = createDataApproval(cocSource2, level1, daw1, p2, ou1); + da2b.setLastUpdated(DateUtils.parseDate("2024-10-8")); + DataApproval da3a = createDataApproval(cocTarget, level1, daw1, p1, ou1); + da3a.setLastUpdated(DateUtils.parseDate("2024-12-8")); + DataApproval da3b = createDataApproval(cocTarget, level1, daw1, p2, ou1); + da3b.setLastUpdated(DateUtils.parseDate("2024-12-9")); + DataApproval da4a = createDataApproval(cocRandom, level1, daw1, p1, ou1); + DataApproval da4b = createDataApproval(cocRandom, level1, daw1, p2, ou1); + + dataApprovalStore.addDataApproval(da1a); + dataApprovalStore.addDataApproval(da1b); + dataApprovalStore.addDataApproval(da2a); + dataApprovalStore.addDataApproval(da2b); + dataApprovalStore.addDataApproval(da3a); + dataApprovalStore.addDataApproval(da3b); + dataApprovalStore.addDataApproval(da4a); + dataApprovalStore.addDataApproval(da4b); + + // pre-merge state + List sourcesPreMerge = + dataApprovalStore.getByCategoryOptionCombo(UID.of(cocSource1, cocSource2)); + List targetPreMerge = + dataApprovalStore.getByCategoryOptionCombo(List.of(UID.of(cocTarget))); + assertEquals(4, sourcesPreMerge.size(), "Expect 4 entries with source COC refs"); + assertEquals(2, targetPreMerge.size(), "Expect 2 entries with target COC refs"); + + // params + MergeParams mergeParams = getMergeParams(); + mergeParams.setDataMergeStrategy(DataMergeStrategy.LAST_UPDATED); + + // when + MergeReport report = categoryOptionComboMergeService.processMerge(mergeParams); + + // then + List sourceItems = + dataApprovalStore.getByCategoryOptionCombo(UID.of(cocSource1, cocSource2)); + List targetItems = + dataApprovalStore.getByCategoryOptionCombo(List.of(UID.of(cocTarget))); + + List allCategoryOptionCombos = + categoryService.getAllCategoryOptionCombos(); + + assertFalse(report.hasErrorMessages()); + assertEquals(0, sourceItems.size(), "Expect 0 entries with source COC refs"); + assertEquals(2, targetItems.size(), "Expect 2 entries with target COC refs"); + assertEquals( + Set.of("2024-12-08", "2024-12-09"), + targetItems.stream() + .map(da -> DateUtils.toMediumDate(da.getLastUpdated())) + .collect(Collectors.toSet()), + "target items should contain the original target Data Approvals lastUpdated dates"); + assertEquals(7, allCategoryOptionCombos.size(), "Expect 7 COCs present"); + assertTrue(allCategoryOptionCombos.contains(cocTarget), "Target COC should be present"); + assertFalse(allCategoryOptionCombos.contains(cocSource1), "Source COC should not be present"); + assertFalse(allCategoryOptionCombos.contains(cocSource2), "Source COC should not be present"); + } + + @Test + @DisplayName( + "Duplicate & non-duplicate DataApprovals are replaced with target COC using LAST_UPDATED strategy") + void duplicateAndNonDuplicateDataApprovalMergeTest() throws ConflictException { + // given + DataApprovalLevel level1 = new DataApprovalLevel(); + level1.setLevel(1); + level1.setName("DAL1"); + manager.save(level1); + + DataApprovalLevel level2 = new DataApprovalLevel(); + level2.setLevel(2); + level2.setName("DAL2"); + manager.save(level2); + + DataApprovalWorkflow daw1 = new DataApprovalWorkflow(); + daw1.setPeriodType(PeriodType.getPeriodType(PeriodTypeEnum.MONTHLY)); + daw1.setName("DAW1"); + daw1.setCategoryCombo(cc1); + manager.save(daw1); + + DataApprovalWorkflow daw2 = new DataApprovalWorkflow(); + daw2.setPeriodType(PeriodType.getPeriodType(PeriodTypeEnum.MONTHLY)); + daw2.setName("DAW2"); + daw2.setCategoryCombo(cc1); + manager.save(daw2); + + DataApproval da1a = createDataApproval(cocSource1, level1, daw1, p1, ou1); + da1a.setLastUpdated(DateUtils.parseDate("2024-12-8")); + DataApproval da1b = createDataApproval(cocSource1, level1, daw1, p2, ou1); + da1b.setLastUpdated(DateUtils.parseDate("2024-10-8")); + DataApproval da2a = createDataApproval(cocSource2, level1, daw1, p1, ou1); + da2a.setLastUpdated(DateUtils.parseDate("2024-6-8")); + DataApproval da2b = createDataApproval(cocSource2, level1, daw1, p2, ou1); + da2b.setLastUpdated(DateUtils.parseDate("2024-10-8")); + DataApproval da3a = createDataApproval(cocTarget, level1, daw1, p1, ou1); + da3a.setLastUpdated(DateUtils.parseDate("2024-12-1")); + DataApproval da3b = createDataApproval(cocTarget, level1, daw1, p2, ou1); + da3b.setLastUpdated(DateUtils.parseDate("2024-12-9")); + DataApproval da4a = createDataApproval(cocRandom, level1, daw1, p1, ou1); + DataApproval da4b = createDataApproval(cocRandom, level1, daw1, p2, ou1); + + dataApprovalStore.addDataApproval(da1a); + dataApprovalStore.addDataApproval(da1b); + dataApprovalStore.addDataApproval(da2a); + dataApprovalStore.addDataApproval(da2b); + dataApprovalStore.addDataApproval(da3a); + dataApprovalStore.addDataApproval(da3b); + dataApprovalStore.addDataApproval(da4a); + dataApprovalStore.addDataApproval(da4b); + + // pre-merge state + List sourcesPreMerge = + dataApprovalStore.getByCategoryOptionCombo(UID.of(cocSource1, cocSource2)); + List targetPreMerge = + dataApprovalStore.getByCategoryOptionCombo(List.of(UID.of(cocTarget))); + assertEquals(4, sourcesPreMerge.size(), "Expect 4 entries with source COC refs"); + assertEquals(2, targetPreMerge.size(), "Expect 2 entries with target COC refs"); + + // params + MergeParams mergeParams = getMergeParams(); + mergeParams.setDataMergeStrategy(DataMergeStrategy.LAST_UPDATED); + + // when + MergeReport report = categoryOptionComboMergeService.processMerge(mergeParams); + + // then + List sourceItems = + dataApprovalStore.getByCategoryOptionCombo(UID.of(cocSource1, cocSource2)); + List targetItems = + dataApprovalStore.getByCategoryOptionCombo(List.of(UID.of(cocTarget))); + + List allCategoryOptionCombos = + categoryService.getAllCategoryOptionCombos(); + + assertFalse(report.hasErrorMessages()); + assertEquals(0, sourceItems.size(), "Expect 0 entries with source COC refs"); + assertEquals(2, targetItems.size(), "Expect 2 entries with target COC refs"); + assertEquals( + Set.of("2024-12-08", "2024-12-09"), + targetItems.stream() + .map(da -> DateUtils.toMediumDate(da.getLastUpdated())) + .collect(Collectors.toSet()), + "target items should contain the original target Data Approvals lastUpdated dates"); + assertEquals(7, allCategoryOptionCombos.size(), "Expect 7 COCs present"); + assertTrue(allCategoryOptionCombos.contains(cocTarget), "Target COC should be present"); + assertFalse(allCategoryOptionCombos.contains(cocSource1), "Source COC should not be present"); + assertFalse(allCategoryOptionCombos.contains(cocSource2), "Source COC should not be present"); + } + + @Test + @DisplayName( + "Duplicate DataApprovals are replaced with target COC using LAST_UPDATED strategy, sources have latest lastUpdated value") + void duplicateDataApprovalSourceLastUpdatedTest() throws ConflictException { + // given + DataApprovalLevel level1 = new DataApprovalLevel(); + level1.setLevel(1); + level1.setName("DAL1"); + manager.save(level1); + + DataApprovalLevel level2 = new DataApprovalLevel(); + level2.setLevel(2); + level2.setName("DAL2"); + manager.save(level2); + + DataApprovalWorkflow daw1 = new DataApprovalWorkflow(); + daw1.setPeriodType(PeriodType.getPeriodType(PeriodTypeEnum.MONTHLY)); + daw1.setName("DAW1"); + daw1.setCategoryCombo(cc1); + manager.save(daw1); + + DataApprovalWorkflow daw2 = new DataApprovalWorkflow(); + daw2.setPeriodType(PeriodType.getPeriodType(PeriodTypeEnum.MONTHLY)); + daw2.setName("DAW2"); + daw2.setCategoryCombo(cc1); + manager.save(daw2); + + DataApproval da1a = createDataApproval(cocSource1, level1, daw1, p1, ou1); + da1a.setLastUpdated(DateUtils.parseDate("2024-12-03")); + DataApproval da1b = createDataApproval(cocSource1, level1, daw1, p2, ou1); + da1b.setLastUpdated(DateUtils.parseDate("2024-12-01")); + DataApproval da2a = createDataApproval(cocSource2, level1, daw1, p1, ou1); + da2a.setLastUpdated(DateUtils.parseDate("2024-11-01")); + DataApproval da2b = createDataApproval(cocSource2, level1, daw1, p2, ou1); + da2b.setLastUpdated(DateUtils.parseDate("2024-12-08")); + DataApproval da3a = createDataApproval(cocTarget, level1, daw1, p1, ou1); + da3a.setLastUpdated(DateUtils.parseDate("2024-06-08")); + DataApproval da3b = createDataApproval(cocTarget, level1, daw1, p2, ou1); + da3b.setLastUpdated(DateUtils.parseDate("2024-06-14")); + DataApproval da4a = createDataApproval(cocRandom, level1, daw1, p1, ou1); + DataApproval da4b = createDataApproval(cocRandom, level1, daw1, p2, ou1); + + dataApprovalStore.addDataApproval(da1a); + dataApprovalStore.addDataApproval(da1b); + dataApprovalStore.addDataApproval(da2a); + dataApprovalStore.addDataApproval(da2b); + dataApprovalStore.addDataApproval(da3a); + dataApprovalStore.addDataApproval(da3b); + dataApprovalStore.addDataApproval(da4a); + dataApprovalStore.addDataApproval(da4b); + + // pre-merge state + List sourcesPreMerge = + dataApprovalStore.getByCategoryOptionCombo(UID.of(cocSource1, cocSource2)); + List targetPreMerge = + dataApprovalStore.getByCategoryOptionCombo(List.of(UID.of(cocTarget))); + assertEquals(4, sourcesPreMerge.size(), "Expect 4 entries with source COC refs"); + assertEquals(2, targetPreMerge.size(), "Expect 2 entries with target COC refs"); + + // params + MergeParams mergeParams = getMergeParams(); + mergeParams.setDataMergeStrategy(DataMergeStrategy.LAST_UPDATED); + + // when + MergeReport report = categoryOptionComboMergeService.processMerge(mergeParams); + + // then + List sourceItems = + dataApprovalStore.getByCategoryOptionCombo(UID.of(cocSource1, cocSource2)); + List targetItems = + dataApprovalStore.getByCategoryOptionCombo(List.of(UID.of(cocTarget))); + + List allCategoryOptionCombos = + categoryService.getAllCategoryOptionCombos(); + + assertFalse(report.hasErrorMessages()); + assertEquals(0, sourceItems.size(), "Expect 0 entries with source COC refs"); + assertEquals(2, targetItems.size(), "Expect 2 entries with target COC refs"); + assertEquals( + Set.of("2024-12-03", "2024-12-08"), + targetItems.stream() + .map(da -> DateUtils.toMediumDate(da.getLastUpdated())) + .collect(Collectors.toSet()), + "target items should contain the original source Data Approvals lastUpdated dates"); + assertEquals(7, allCategoryOptionCombos.size(), "Expect 7 COCs present"); + assertTrue(allCategoryOptionCombos.contains(cocTarget), "Target COC should be present"); + assertFalse(allCategoryOptionCombos.contains(cocSource1), "Source COC should not be present"); + assertFalse(allCategoryOptionCombos.contains(cocSource2), "Source COC should not be present"); + } + + @Test + @DisplayName( + "DataApprovals with references to source COCs are deleted when using DISCARD strategy") + void dataApprovalMergeCocDiscardTest() throws ConflictException { + // given + DataApprovalLevel level1 = new DataApprovalLevel(); + level1.setLevel(1); + level1.setName("DAL1"); + manager.save(level1); + + DataApprovalLevel level2 = new DataApprovalLevel(); + level2.setLevel(2); + level2.setName("DAL2"); + manager.save(level2); + + DataApprovalWorkflow daw1 = new DataApprovalWorkflow(); + daw1.setPeriodType(PeriodType.getPeriodType(PeriodTypeEnum.MONTHLY)); + daw1.setName("DAW1"); + daw1.setCategoryCombo(cc1); + manager.save(daw1); + + DataApprovalWorkflow daw2 = new DataApprovalWorkflow(); + daw2.setPeriodType(PeriodType.getPeriodType(PeriodTypeEnum.MONTHLY)); + daw2.setName("DAW2"); + daw2.setCategoryCombo(cc1); + manager.save(daw2); + + DataApproval da1a = createDataApproval(cocSource1, level1, daw1, p1, ou1); + da1a.setLastUpdated(DateUtils.parseDate("2024-12-03")); + DataApproval da1b = createDataApproval(cocSource1, level1, daw1, p2, ou1); + da1b.setLastUpdated(DateUtils.parseDate("2024-12-01")); + DataApproval da2a = createDataApproval(cocSource2, level1, daw1, p1, ou1); + da2a.setLastUpdated(DateUtils.parseDate("2024-11-01")); + DataApproval da2b = createDataApproval(cocSource2, level1, daw1, p2, ou1); + da2b.setLastUpdated(DateUtils.parseDate("2024-12-08")); + DataApproval da3a = createDataApproval(cocTarget, level1, daw1, p1, ou1); + da3a.setLastUpdated(DateUtils.parseDate("2024-06-08")); + DataApproval da3b = createDataApproval(cocTarget, level1, daw1, p2, ou1); + da3b.setLastUpdated(DateUtils.parseDate("2024-06-14")); + DataApproval da4a = createDataApproval(cocRandom, level1, daw1, p1, ou1); + DataApproval da4b = createDataApproval(cocRandom, level1, daw1, p2, ou1); + + dataApprovalStore.addDataApproval(da1a); + dataApprovalStore.addDataApproval(da1b); + dataApprovalStore.addDataApproval(da2a); + dataApprovalStore.addDataApproval(da2b); + dataApprovalStore.addDataApproval(da3a); + dataApprovalStore.addDataApproval(da3b); + dataApprovalStore.addDataApproval(da4a); + dataApprovalStore.addDataApproval(da4b); + + // pre merge state + List sourceItemsBefore = + dataApprovalStore.getByCategoryOptionCombo(UID.of(cocSource1, cocSource2)); + List targetItemsBefore = + dataApprovalStore.getByCategoryOptionCombo(List.of(UID.of(cocTarget))); + + assertEquals(4, sourceItemsBefore.size(), "Expect 4 entries with source COC refs"); + assertEquals(2, targetItemsBefore.size(), "Expect 2 entry with target COC ref only"); + + // params + MergeParams mergeParams = getMergeParams(); + mergeParams.setDataMergeStrategy(DataMergeStrategy.DISCARD); + + // when + MergeReport report = categoryOptionComboMergeService.processMerge(mergeParams); + + // then + List sourceItems = + dataApprovalStore.getByCategoryOptionCombo(UID.of(cocSource1, cocSource2)); + List targetItems = + dataApprovalStore.getByCategoryOptionCombo(List.of(UID.of(cocTarget))); + + List allCategoryOptionCombos = + categoryService.getAllCategoryOptionCombos(); + + assertFalse(report.hasErrorMessages()); + assertEquals(0, sourceItems.size(), "Expect 0 entries with source COC refs"); + assertEquals(2, targetItems.size(), "Expect 2 entry with target COC ref only"); + assertEquals(7, allCategoryOptionCombos.size(), "Expect 7 COCs present"); + assertTrue(allCategoryOptionCombos.contains(cocTarget), "Target COC should be present"); + assertFalse(allCategoryOptionCombos.contains(cocSource1), "Source COC should not be present"); + assertFalse(allCategoryOptionCombos.contains(cocSource2), "Source COC should not be present"); + } + + // ----------------------------- + // -- Event eventDataValues -- + // ----------------------------- + @Test + @DisplayName( + "Event attributeOptionCombo references to source COCs are replaced with target COC when using LAST_UPDATED, source COCs are not deleted") + void eventMergeTest() throws ConflictException { + // given + TrackedEntity trackedEntity = createTrackedEntity(ou1); + manager.save(trackedEntity); + Enrollment enrollment = createEnrollment(program, trackedEntity, ou1); + manager.save(enrollment); + ProgramStage stage = createProgramStage('s', 2); + manager.save(stage); + + Event e1 = createEvent(stage, enrollment, ou1); + e1.setAttributeOptionCombo(cocSource1); + Event e2 = createEvent(stage, enrollment, ou1); + e2.setAttributeOptionCombo(cocSource2); + Event e3 = createEvent(stage, enrollment, ou1); + e3.setAttributeOptionCombo(cocTarget); + Event e4 = createEvent(stage, enrollment, ou1); + e4.setAttributeOptionCombo(cocRandom); + + manager.save(List.of(e1, e2, e3, e4)); + + // params + MergeParams mergeParams = getMergeParams(); + mergeParams.setDeleteSources(false); + mergeParams.setDataMergeStrategy(DataMergeStrategy.LAST_UPDATED); + + // when + MergeReport report = categoryOptionComboMergeService.processMerge(mergeParams); + + // then + List allEvents = eventStore.getAll(); + List allCategoryOptionCombos = + categoryService.getAllCategoryOptionCombos(); + + assertFalse(report.hasErrorMessages()); + assertEquals(4, allEvents.size(), "Expect 4 entries still"); + assertTrue( + allEvents.stream() + .map(e -> e.getAttributeOptionCombo().getUid()) + .collect(Collectors.toSet()) + .containsAll(Set.of(cocTarget.getUid(), cocRandom.getUid())), + "All events should only have references to the target coc and the random coc"); + assertEquals(9, allCategoryOptionCombos.size(), "Expect 9 COCs present"); + assertTrue( + allCategoryOptionCombos.stream() + .map(BaseIdentifiableObject::getUid) + .collect(Collectors.toSet()) + .containsAll(Set.of(cocSource1.getUid(), cocSource2.getUid(), cocTarget.getUid()))); + } + + @Test + @DisplayName( + "Event eventDataValues references to source COCs are deleted using DISCARD, source COCs are deleted") + void eventMergeSourcesDeletedTest() throws ConflictException { + // given + TrackedEntity trackedEntity = createTrackedEntity(ou1); + manager.save(trackedEntity); + Enrollment enrollment = createEnrollment(program, trackedEntity, ou1); + manager.save(enrollment); + ProgramStage stage = createProgramStage('s', 2); + manager.save(stage); + + Event e1 = createEvent(stage, enrollment, ou1); + e1.setAttributeOptionCombo(cocSource1); + Event e2 = createEvent(stage, enrollment, ou1); + e2.setAttributeOptionCombo(cocSource2); + Event e3 = createEvent(stage, enrollment, ou1); + e3.setAttributeOptionCombo(cocTarget); + Event e4 = createEvent(stage, enrollment, ou1); + e4.setAttributeOptionCombo(cocRandom); + + manager.save(List.of(e1, e2, e3, e4)); + + // params + MergeParams mergeParams = getMergeParams(); + + // when + MergeReport report = categoryOptionComboMergeService.processMerge(mergeParams); + + // then + List allEvents = eventStore.getAll(); + List allCategoryOptionCombos = + categoryService.getAllCategoryOptionCombos(); + + assertFalse(report.hasErrorMessages()); + assertEquals(2, allEvents.size(), "Expect 2 entries still"); + assertEquals(7, allCategoryOptionCombos.size(), "Expect 7 COCs present"); + assertTrue(allCategoryOptionCombos.contains(cocTarget), "target COC should be present"); + assertFalse(allCategoryOptionCombos.contains(cocSource1), "source COC should not be present"); + assertFalse(allCategoryOptionCombos.contains(cocSource2), "source COC should not be present"); + } + + // -------------------------------- + // --CompleteDataSetRegistration-- + // -------------------------------- + @Test + @DisplayName( + "CompleteDataSetRegistration with references to source COCs are deleted when using DISCARD strategy") + void cdsrMergeCocDiscardTest() throws ConflictException { + // given + DataSet ds1 = createDataSet('1'); + ds1.setPeriodType(PeriodType.getPeriodType(PeriodTypeEnum.MONTHLY)); + manager.save(ds1); + + CompleteDataSetRegistration cdsr1 = createCdsr(ds1, ou1, p1, cocSource1); + CompleteDataSetRegistration cdsr2 = createCdsr(ds1, ou1, p1, cocSource2); + CompleteDataSetRegistration cdsr3 = createCdsr(ds1, ou1, p1, cocTarget); + CompleteDataSetRegistration cdsr4 = createCdsr(ds1, ou1, p1, cocRandom); + completeDataSetRegistrationStore.saveCompleteDataSetRegistration(cdsr1); + completeDataSetRegistrationStore.saveCompleteDataSetRegistration(cdsr2); + completeDataSetRegistrationStore.saveCompleteDataSetRegistration(cdsr3); + completeDataSetRegistrationStore.saveCompleteDataSetRegistration(cdsr4); + + // params + MergeParams mergeParams = getMergeParams(); + mergeParams.setDataMergeStrategy(DataMergeStrategy.DISCARD); + + // when + MergeReport report = categoryOptionComboMergeService.processMerge(mergeParams); + + // then + List sourceItems = + completeDataSetRegistrationStore.getAllByCategoryOptionCombo( + UID.of(cocSource1, cocSource2)); + List targetItems = + completeDataSetRegistrationStore.getAllByCategoryOptionCombo(List.of(UID.of(cocTarget))); + + List allCategoryOptionCombos = + categoryService.getAllCategoryOptionCombos(); + + assertFalse(report.hasErrorMessages()); + assertEquals(0, sourceItems.size(), "Expect 0 entries with source COC refs"); + assertEquals(1, targetItems.size(), "Expect 1 entry with target COC ref only"); + assertEquals(7, allCategoryOptionCombos.size(), "Expect 7 COCs present"); + assertTrue(allCategoryOptionCombos.contains(cocTarget), "Target COC should be present"); + assertFalse(allCategoryOptionCombos.contains(cocSource1), "Source COC should not be present"); + assertFalse(allCategoryOptionCombos.contains(cocSource2), "Source COC should not be present"); + } + + @Test + @DisplayName( + "CompleteDataSetRegistration with references to source COCs are merged when using LAST_UPDATED strategy, no duplicates") + void cdsrMergeNoDuplicatesTest() throws ConflictException { + // given + DataSet ds1 = createDataSet('1'); + ds1.setPeriodType(PeriodType.getPeriodType(PeriodTypeEnum.MONTHLY)); + manager.save(ds1); + + DataSet ds2 = createDataSet('2'); + ds2.setPeriodType(PeriodType.getPeriodType(PeriodTypeEnum.MONTHLY)); + manager.save(ds2); + + CompleteDataSetRegistration cdsr1 = createCdsr(ds1, ou1, p1, cocSource1); + CompleteDataSetRegistration cdsr2 = createCdsr(ds2, ou1, p3, cocSource2); + CompleteDataSetRegistration cdsr3 = createCdsr(ds1, ou3, p2, cocTarget); + CompleteDataSetRegistration cdsr4 = createCdsr(ds2, ou2, p1, cocRandom); + completeDataSetRegistrationStore.saveWithoutUpdatingLastUpdated(cdsr1); + completeDataSetRegistrationStore.saveWithoutUpdatingLastUpdated(cdsr2); + completeDataSetRegistrationStore.saveWithoutUpdatingLastUpdated(cdsr3); + completeDataSetRegistrationStore.saveWithoutUpdatingLastUpdated(cdsr4); + + // params + MergeParams mergeParams = getMergeParams(); + mergeParams.setDataMergeStrategy(DataMergeStrategy.LAST_UPDATED); + + // when + MergeReport report = categoryOptionComboMergeService.processMerge(mergeParams); + + // then + List sourceItems = + completeDataSetRegistrationStore.getAllByCategoryOptionCombo( + UID.of(cocSource1, cocSource2)); + List targetItems = + completeDataSetRegistrationStore.getAllByCategoryOptionCombo(List.of(UID.of(cocTarget))); + + List allCategoryOptionCombos = + categoryService.getAllCategoryOptionCombos(); + + assertFalse(report.hasErrorMessages()); + assertEquals(0, sourceItems.size(), "Expect 0 entries with source COC refs"); + assertEquals(3, targetItems.size(), "Expect 3 entries with target COC ref only"); + assertEquals(7, allCategoryOptionCombos.size(), "Expect 7 COCs present"); + assertTrue(allCategoryOptionCombos.contains(cocTarget), "Target COC should be present"); + assertFalse(allCategoryOptionCombos.contains(cocSource1), "Source COC should not be present"); + assertFalse(allCategoryOptionCombos.contains(cocSource2), "Source COC should not be present"); + } + + @Test + @DisplayName( + "Merge CompleteDataSetRegistration with references to source COCs, using LAST_UPDATED strategy, with duplicates, target has latest lastUpdated") + void cdsrMergeDuplicatesTargetLastUpdatedTest() throws ConflictException { + // given + DataSet ds1 = createDataSet('1'); + ds1.setPeriodType(PeriodType.getPeriodType(PeriodTypeEnum.MONTHLY)); + manager.save(ds1); + + DataSet ds2 = createDataSet('2'); + ds2.setPeriodType(PeriodType.getPeriodType(PeriodTypeEnum.MONTHLY)); + manager.save(ds2); + + CompleteDataSetRegistration cdsr1 = createCdsr(ds1, ou1, p1, cocSource1); + cdsr1.setLastUpdated(DateUtils.parseDate("2024-11-01")); + CompleteDataSetRegistration cdsr2 = createCdsr(ds1, ou1, p1, cocSource2); + cdsr2.setLastUpdated(DateUtils.parseDate("2024-10-01")); + CompleteDataSetRegistration cdsr3 = createCdsr(ds1, ou1, p1, cocTarget); + cdsr3.setLastUpdated(DateUtils.parseDate("2024-12-05")); + CompleteDataSetRegistration cdsr4 = createCdsr(ds2, ou2, p1, cocRandom); + completeDataSetRegistrationStore.saveWithoutUpdatingLastUpdated(cdsr1); + completeDataSetRegistrationStore.saveWithoutUpdatingLastUpdated(cdsr2); + completeDataSetRegistrationStore.saveWithoutUpdatingLastUpdated(cdsr3); + completeDataSetRegistrationStore.saveWithoutUpdatingLastUpdated(cdsr4); + + // params + MergeParams mergeParams = getMergeParams(); + mergeParams.setDataMergeStrategy(DataMergeStrategy.LAST_UPDATED); + + // when + MergeReport report = categoryOptionComboMergeService.processMerge(mergeParams); + + // then + List sourceItems = + completeDataSetRegistrationStore.getAllByCategoryOptionCombo( + UID.of(cocSource1, cocSource2)); + List targetItems = + completeDataSetRegistrationStore.getAllByCategoryOptionCombo(List.of(UID.of(cocTarget))); + + List allCategoryOptionCombos = + categoryService.getAllCategoryOptionCombos(); + + assertFalse(report.hasErrorMessages()); + assertEquals(0, sourceItems.size(), "Expect 0 entries with source COC refs"); + assertEquals(1, targetItems.size(), "Expect 1 entries with target COC ref only"); + assertEquals(7, allCategoryOptionCombos.size(), "Expect 7 COCs present"); + assertEquals( + Set.of("2024-12-05"), + targetItems.stream() + .map(da -> DateUtils.toMediumDate(da.getLastUpdated())) + .collect(Collectors.toSet()), + "target items should contain target Data Approvals lastUpdated dates"); + assertTrue(allCategoryOptionCombos.contains(cocTarget), "Target COC should be present"); + assertFalse(allCategoryOptionCombos.contains(cocSource1), "Source COC should not be present"); + assertFalse(allCategoryOptionCombos.contains(cocSource2), "Source COC should not be present"); + } + + @Test + @DisplayName( + "Merge CompleteDataSetRegistration with references to source COCs, using LAST_UPDATED strategy, with duplicates, sources have latest lastUpdated") + void cdsrMergeDuplicatesSourcesLastUpdatedTest() throws ConflictException { + // given + DataSet ds1 = createDataSet('1'); + ds1.setPeriodType(PeriodType.getPeriodType(PeriodTypeEnum.MONTHLY)); + manager.save(ds1); + + DataSet ds2 = createDataSet('2'); + ds2.setPeriodType(PeriodType.getPeriodType(PeriodTypeEnum.MONTHLY)); + manager.save(ds2); + + CompleteDataSetRegistration cdsr1 = createCdsr(ds1, ou1, p1, cocSource1); + cdsr1.setLastUpdated(DateUtils.parseDate("2024-10-01")); + CompleteDataSetRegistration cdsr2 = createCdsr(ds1, ou1, p1, cocSource2); + cdsr2.setLastUpdated(DateUtils.parseDate("2024-11-01")); + CompleteDataSetRegistration cdsr3 = createCdsr(ds1, ou1, p1, cocTarget); + cdsr3.setLastUpdated(DateUtils.parseDate("2024-05-05")); + CompleteDataSetRegistration cdsr4 = createCdsr(ds2, ou2, p1, cocRandom); + completeDataSetRegistrationStore.saveWithoutUpdatingLastUpdated(cdsr1); + completeDataSetRegistrationStore.saveWithoutUpdatingLastUpdated(cdsr2); + completeDataSetRegistrationStore.saveWithoutUpdatingLastUpdated(cdsr3); + completeDataSetRegistrationStore.saveWithoutUpdatingLastUpdated(cdsr4); + + // params + MergeParams mergeParams = getMergeParams(); + mergeParams.setDataMergeStrategy(DataMergeStrategy.LAST_UPDATED); + + // when + MergeReport report = categoryOptionComboMergeService.processMerge(mergeParams); + + // then + List sourceItems = + completeDataSetRegistrationStore.getAllByCategoryOptionCombo( + UID.of(cocSource1, cocSource2)); + List targetItems = + completeDataSetRegistrationStore.getAllByCategoryOptionCombo(List.of(UID.of(cocTarget))); + + List allCategoryOptionCombos = + categoryService.getAllCategoryOptionCombos(); + + assertFalse(report.hasErrorMessages()); + assertEquals(0, sourceItems.size(), "Expect 0 entries with source COC refs"); + assertEquals(1, targetItems.size(), "Expect 1 entries with target COC ref only"); + assertEquals(7, allCategoryOptionCombos.size(), "Expect 7 COCs present"); + assertEquals( + Set.of("2024-11-01"), + targetItems.stream() + .map(da -> DateUtils.toMediumDate(da.getLastUpdated())) + .collect(Collectors.toSet()), + "target items should contain source registration lastUpdated dates"); + assertTrue(allCategoryOptionCombos.contains(cocTarget), "Target COC should be present"); + assertFalse(allCategoryOptionCombos.contains(cocSource1), "Source COC should not be present"); + assertFalse(allCategoryOptionCombos.contains(cocSource2), "Source COC should not be present"); + } + + @Test + @DisplayName( + "Merge CompleteDataSetRegistration with references to source COCs, using LAST_UPDATED strategy, with duplicates & non-duplicates") + void cdsrMergeDuplicatesNonDuplicatesTest() throws ConflictException { + // given + DataSet ds1 = createDataSet('1'); + ds1.setPeriodType(PeriodType.getPeriodType(PeriodTypeEnum.MONTHLY)); + manager.save(ds1); + + DataSet ds2 = createDataSet('2'); + ds2.setPeriodType(PeriodType.getPeriodType(PeriodTypeEnum.MONTHLY)); + manager.save(ds2); + + CompleteDataSetRegistration cdsr1 = createCdsr(ds1, ou1, p1, cocSource1); + cdsr1.setLastUpdated(DateUtils.parseDate("2024-10-01")); + CompleteDataSetRegistration cdsr2 = createCdsr(ds2, ou2, p1, cocSource2); + cdsr2.setLastUpdated(DateUtils.parseDate("2024-11-11")); + CompleteDataSetRegistration cdsr3 = createCdsr(ds1, ou1, p1, cocTarget); + cdsr3.setLastUpdated(DateUtils.parseDate("2024-12-05")); + CompleteDataSetRegistration cdsr4 = createCdsr(ds2, ou2, p1, cocRandom); + completeDataSetRegistrationStore.saveWithoutUpdatingLastUpdated(cdsr1); + completeDataSetRegistrationStore.saveWithoutUpdatingLastUpdated(cdsr2); + completeDataSetRegistrationStore.saveWithoutUpdatingLastUpdated(cdsr3); + completeDataSetRegistrationStore.saveWithoutUpdatingLastUpdated(cdsr4); + + // params + MergeParams mergeParams = getMergeParams(); + mergeParams.setDataMergeStrategy(DataMergeStrategy.LAST_UPDATED); + + // when + MergeReport report = categoryOptionComboMergeService.processMerge(mergeParams); + + // then + List sourceItems = + completeDataSetRegistrationStore.getAllByCategoryOptionCombo( + UID.of(cocSource1, cocSource2)); + List targetItems = + completeDataSetRegistrationStore.getAllByCategoryOptionCombo(List.of(UID.of(cocTarget))); + + List allCategoryOptionCombos = + categoryService.getAllCategoryOptionCombos(); + + assertFalse(report.hasErrorMessages()); + assertEquals(0, sourceItems.size(), "Expect 0 entries with source COC refs"); + assertEquals(2, targetItems.size(), "Expect 2 entries with target COC ref only"); + assertEquals(7, allCategoryOptionCombos.size(), "Expect 7 COCs present"); + assertEquals( + Set.of("2024-12-05", "2024-11-11"), + targetItems.stream() + .map(da -> DateUtils.toMediumDate(da.getLastUpdated())) + .collect(Collectors.toSet()), + "target items should contain target & source registration lastUpdated dates"); + assertTrue(allCategoryOptionCombos.contains(cocTarget), "Target COC should be present"); + assertFalse(allCategoryOptionCombos.contains(cocSource1), "Source COC should not be present"); + assertFalse(allCategoryOptionCombos.contains(cocSource2), "Source COC should not be present"); + } + + private CompleteDataSetRegistration createCdsr( + DataSet ds, OrganisationUnit ou, Period p, CategoryOptionCombo coc) { + CompleteDataSetRegistration cdsr = new CompleteDataSetRegistration(); + cdsr.setSource(ou); + cdsr.setAttributeOptionCombo(coc); + cdsr.setPeriod(p); + cdsr.setDataSet(ds); + cdsr.setCompleted(true); + return cdsr; + } + + private MergeParams getMergeParams() { + MergeParams mergeParams = new MergeParams(); + mergeParams.setSources(UID.of(List.of(cocSource1.getUid(), cocSource2.getUid()))); + mergeParams.setTarget(UID.of(cocTarget.getUid())); + mergeParams.setDataMergeStrategy(DataMergeStrategy.DISCARD); + mergeParams.setDeleteSources(true); + return mergeParams; + } + + private CategoryOptionCombo getCocWithOptions(String co1, String co2) { + List allCategoryOptionCombos = + categoryService.getAllCategoryOptionCombos(); + + return allCategoryOptionCombos.stream() + .filter( + coc -> { + List categoryOptions = + coc.getCategoryOptions().stream().map(BaseIdentifiableObject::getName).toList(); + return categoryOptions.containsAll(List.of(co1, co2)); + }) + .toList() + .get(0); + } + + private DataValueAudit createDataValueAudit(CategoryOptionCombo coc, String value, Period p) { + DataValueAudit dva = new DataValueAudit(); + dva.setDataElement(de1); + dva.setValue(value); + dva.setAuditType(AuditOperationType.CREATE); + dva.setCreated(new Date()); + dva.setCategoryOptionCombo(coc); + dva.setAttributeOptionCombo(coc); + dva.setPeriod(p); + dva.setOrganisationUnit(ou1); + return dva; + } + + private DataApprovalAudit createDataApprovalAudit( + CategoryOptionCombo coc, DataApprovalLevel level, DataApprovalWorkflow workflow, Period p) { + DataApprovalAudit daa = new DataApprovalAudit(); + daa.setAttributeOptionCombo(coc); + daa.setOrganisationUnit(ou1); + daa.setLevel(level); + daa.setWorkflow(workflow); + daa.setPeriod(p); + daa.setAction(APPROVE); + daa.setCreated(new Date()); + daa.setCreator(getCurrentUser()); + return daa; + } + + private DataApproval createDataApproval( + CategoryOptionCombo coc, + DataApprovalLevel level, + DataApprovalWorkflow workflow, + Period p, + OrganisationUnit org) { + DataApproval da = new DataApproval(level, workflow, p, org, coc); + da.setCreated(new Date()); + da.setCreator(getCurrentUser()); + return da; + } + + private DataValueAuditQueryParams getQueryParams(CategoryOptionCombo coc) { + return new DataValueAuditQueryParams().setCategoryOptionCombo(coc); + } +} diff --git a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/minmax/MinMaxDataElementStoreTest.java b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/minmax/MinMaxDataElementStoreTest.java index 826ae81ddc24..3e8f10389e19 100644 --- a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/minmax/MinMaxDataElementStoreTest.java +++ b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/minmax/MinMaxDataElementStoreTest.java @@ -37,7 +37,9 @@ import java.util.ArrayList; import java.util.List; import org.hisp.dhis.category.CategoryOptionCombo; +import org.hisp.dhis.category.CategoryOptionComboStore; import org.hisp.dhis.category.CategoryService; +import org.hisp.dhis.common.UID; import org.hisp.dhis.dataelement.DataElement; import org.hisp.dhis.dataelement.DataElementService; import org.hisp.dhis.organisationunit.OrganisationUnit; @@ -61,6 +63,7 @@ class MinMaxDataElementStoreTest extends PostgresIntegrationTestBase { @Autowired private OrganisationUnitService organisationUnitService; @Autowired private CategoryService categoryService; + @Autowired private CategoryOptionComboStore categoryOptionComboStore; @Autowired private MinMaxDataElementStore minMaxDataElementStore; @@ -207,6 +210,57 @@ void getMinMaxDataElementsByDataElement() { .containsAll(List.of(deW.getUid(), deX.getUid()))); } + @Test + @DisplayName("retrieving min max data elements by cat option combo returns expected entries") + void getMinMaxDataElementsByCoc() { + // given + DataElement deW = createDataElementAndSave('W'); + DataElement deX = createDataElementAndSave('X'); + DataElement deY = createDataElementAndSave('Y'); + DataElement deZ = createDataElementAndSave('Z'); + + MinMaxDataElement mmde1 = createMinMaxDataElementAndSave(deW); + CategoryOptionCombo coc1 = createCategoryOptionCombo('A'); + coc1.setCategoryCombo(categoryService.getDefaultCategoryCombo()); + categoryOptionComboStore.save(coc1); + mmde1.setOptionCombo(coc1); + + MinMaxDataElement mmde2 = createMinMaxDataElementAndSave(deX); + CategoryOptionCombo coc2 = createCategoryOptionCombo('B'); + coc2.setCategoryCombo(categoryService.getDefaultCategoryCombo()); + categoryOptionComboStore.save(coc2); + mmde2.setOptionCombo(coc2); + + MinMaxDataElement mmde3 = createMinMaxDataElementAndSave(deY); + CategoryOptionCombo coc3 = createCategoryOptionCombo('C'); + coc3.setCategoryCombo(categoryService.getDefaultCategoryCombo()); + categoryOptionComboStore.save(coc3); + mmde3.setOptionCombo(coc3); + + MinMaxDataElement mmde4 = createMinMaxDataElementAndSave(deZ); + CategoryOptionCombo coc4 = createCategoryOptionCombo('D'); + coc4.setCategoryCombo(categoryService.getDefaultCategoryCombo()); + categoryOptionComboStore.save(coc4); + mmde4.setOptionCombo(coc4); + + // when + List allByCoc = + minMaxDataElementStore.getByCategoryOptionCombo(UID.of(coc1, coc2)); + + // then + assertEquals(2, allByCoc.size()); + assertTrue( + allByCoc.stream() + .map(mmde -> mmde.getOptionCombo().getUid()) + .toList() + .containsAll(List.of(coc1.getUid(), coc2.getUid()))); + assertTrue( + allByCoc.stream() + .map(mmde -> mmde.getDataElement().getUid()) + .toList() + .containsAll(List.of(deW.getUid(), deX.getUid()))); + } + private DataElement createDataElementAndSave(char c) { DataElement de = createDataElement(c); dataElementService.addDataElement(de); diff --git a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/predictor/PredictorStoreTest.java b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/predictor/PredictorStoreTest.java index 1dd9dcbde805..fd57b9383411 100644 --- a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/predictor/PredictorStoreTest.java +++ b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/predictor/PredictorStoreTest.java @@ -37,6 +37,7 @@ import java.util.Set; import org.hisp.dhis.category.CategoryOptionCombo; import org.hisp.dhis.category.CategoryService; +import org.hisp.dhis.common.UID; import org.hisp.dhis.dataelement.DataElement; import org.hisp.dhis.dataelement.DataElementService; import org.hisp.dhis.expression.Expression; @@ -476,7 +477,7 @@ void generatorWithDataElementTest() { @Test @DisplayName( - "Retrieving Predictors whose sample skit test contains DataElements returns expected results") + "Retrieving Predictors whose sample skip test contains DataElements returns expected results") void sampleSkipTestWithDataElementTest() { // given DataElement de1 = createDataElement('1'); @@ -524,4 +525,74 @@ void sampleSkipTestWithDataElementTest() { allWithSampleSkipTestDEs.containsAll(List.of(p1, p2)), "Retrieved result set should contain both Predictors"); } + + @Test + @DisplayName("Retrieving Predictors by CategoryOptionCombo returns expected results") + void getByCocTest() { + // given + CategoryOptionCombo coc1 = createCategoryOptionCombo('1'); + coc1.setCategoryCombo(categoryService.getDefaultCategoryCombo()); + categoryService.addCategoryOptionCombo(coc1); + + CategoryOptionCombo coc2 = createCategoryOptionCombo('2'); + coc2.setCategoryCombo(categoryService.getDefaultCategoryCombo()); + categoryService.addCategoryOptionCombo(coc2); + + CategoryOptionCombo coc3 = createCategoryOptionCombo('3'); + coc3.setCategoryCombo(categoryService.getDefaultCategoryCombo()); + categoryService.addCategoryOptionCombo(coc3); + + Predictor p1 = + createPredictor( + dataElementA, + coc1, + "A", + expressionC, + createExpression2('a', "#{test123}"), + periodType, + orgUnitLevel1, + 1, + 1, + 1); + + Predictor p2 = + createPredictor( + dataElementX, + coc2, + "B", + expressionD, + createExpression2('a', "#{test123}"), + periodType, + orgUnitLevel1, + 1, + 1, + 1); + + Predictor p3 = + createPredictor( + dataElementB, + coc3, + "C", + expressionB, + createExpression2('a', "#{test123}"), + periodType, + orgUnitLevel1, + 1, + 1, + 1); + + predictorStore.save(p1); + predictorStore.save(p2); + predictorStore.save(p3); + + // when + List allByCoc = + predictorStore.getByCategoryOptionCombo(UID.of(coc1.getUid(), coc2.getUid())); + + // then + assertEquals(2, allByCoc.size()); + assertTrue( + allByCoc.containsAll(List.of(p1, p2)), + "Retrieved result set should contain both Predictors"); + } } diff --git a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/sms/SMSCommandStoreTest.java b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/sms/SMSCommandStoreTest.java index ed7efd3ac9a1..4291222da416 100644 --- a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/sms/SMSCommandStoreTest.java +++ b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/sms/SMSCommandStoreTest.java @@ -32,6 +32,9 @@ import java.util.List; import java.util.Set; +import org.hisp.dhis.category.CategoryOptionCombo; +import org.hisp.dhis.category.CategoryOptionComboStore; +import org.hisp.dhis.common.UID; import org.hisp.dhis.dataelement.DataElement; import org.hisp.dhis.dataelement.DataElementService; import org.hisp.dhis.sms.command.SMSCommand; @@ -54,6 +57,7 @@ class SMSCommandStoreTest extends PostgresIntegrationTestBase { @Autowired private DataElementService dataElementService; @Autowired private SMSCommandStore smsCommandStore; + @Autowired private CategoryOptionComboStore categoryOptionComboStore; @Test @DisplayName("retrieving SMS Codes by data element returns expected entries") @@ -81,13 +85,59 @@ void getSMSCodesByDataElementTest() { .containsAll(List.of(deW.getUid(), deX.getUid(), deY.getUid()))); } + @Test + @DisplayName("retrieving SMS Codes by cat option combos returns expected entries") + void getByCocTest() { + // given + DataElement deW = createDataElementAndSave('W'); + DataElement deX = createDataElementAndSave('X'); + DataElement deY = createDataElementAndSave('Y'); + DataElement deZ = createDataElementAndSave('Z'); + + SMSCode code1 = createSMSCodeAndSave(deW, "code 1"); + CategoryOptionCombo coc1 = createCategoryOptionCombo('A'); + coc1.setCategoryCombo(categoryService.getDefaultCategoryCombo()); + categoryOptionComboStore.save(coc1); + code1.setOptionId(coc1); + + SMSCode code2 = createSMSCodeAndSave(deX, "code 2"); + CategoryOptionCombo coc2 = createCategoryOptionCombo('B'); + coc2.setCategoryCombo(categoryService.getDefaultCategoryCombo()); + categoryOptionComboStore.save(coc2); + code2.setOptionId(coc2); + + SMSCode code3 = createSMSCodeAndSave(deY, "code 3"); + CategoryOptionCombo coc3 = createCategoryOptionCombo('C'); + coc3.setCategoryCombo(categoryService.getDefaultCategoryCombo()); + categoryOptionComboStore.save(coc3); + code3.setOptionId(coc3); + + SMSCode code4 = createSMSCodeAndSave(deZ, "code 4"); + CategoryOptionCombo coc4 = createCategoryOptionCombo('D'); + coc4.setCategoryCombo(categoryService.getDefaultCategoryCombo()); + categoryOptionComboStore.save(coc4); + code4.setOptionId(coc4); + + // when + List allByCoc = smsCommandStore.getCodesByCategoryOptionCombo(UID.of(coc1, coc2)); + + // then + assertEquals(2, allByCoc.size()); + assertTrue( + allByCoc.stream() + .map(code -> code.getOptionId().getUid()) + .toList() + .containsAll(List.of(coc1.getUid(), coc2.getUid())), + "Codes should contain correct COC UIDs"); + } + private DataElement createDataElementAndSave(char c) { DataElement de = createDataElement(c); dataElementService.addDataElement(de); return de; } - private void createSMSCodeAndSave(DataElement de, String code) { + private SMSCode createSMSCodeAndSave(DataElement de, String code) { SMSCode smsCode = new SMSCode(); smsCode.setCode("Code " + code); smsCode.setDataElement(de); @@ -96,5 +146,6 @@ private void createSMSCodeAndSave(DataElement de, String code) { smsCommand.setCodes(Set.of(smsCode)); smsCommand.setName("CMD " + code); smsCommandStore.save(smsCommand); + return smsCode; } } diff --git a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/tracker/export/trackedentity/TrackedEntityServiceTest.java b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/tracker/export/trackedentity/TrackedEntityServiceTest.java index 160bb6c6281c..6e99d46da1c8 100644 --- a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/tracker/export/trackedentity/TrackedEntityServiceTest.java +++ b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/tracker/export/trackedentity/TrackedEntityServiceTest.java @@ -706,7 +706,7 @@ void shouldTrackedEntityIncludeSpecificOpenProgram() List trackedEntities = trackedEntityService.getTrackedEntities(operationParams); - assertContainsOnly(List.of(trackedEntityA), trackedEntities); + assertContainsOnly(List.of(trackedEntityA), trackedEntities, TrackedEntity::getUid); assertContainsOnly( Set.of("A", "B", "C"), attributeNames(trackedEntities.get(0).getTrackedEntityAttributeValues())); @@ -724,7 +724,7 @@ void shouldReturnEnrollmentsFromSpecifiedProgramWhenRequestingSingleTrackedEntit trackedEntityService.getTrackedEntity( UID.of(trackedEntityA), UID.of(programA), TrackedEntityParams.TRUE); - assertContainsOnly(Set.of(enrollmentA), trackedEntity.getEnrollments()); + assertContainsOnly(Set.of(enrollmentA), trackedEntity.getEnrollments(), Enrollment::getUid); } @Test @@ -740,7 +740,9 @@ void shouldReturnAllEnrollmentsWhenRequestingSingleTrackedEntityAndNoProgramSpec UID.of(trackedEntityA), null, TrackedEntityParams.TRUE); assertContainsOnly( - Set.of(enrollmentA, enrollmentB, enrollmentProgramB), trackedEntity.getEnrollments()); + Set.of(enrollmentA, enrollmentB, enrollmentProgramB), + trackedEntity.getEnrollments(), + Enrollment::getUid); } @Test diff --git a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/CategoryOptionComboControllerTest.java b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/CategoryOptionComboControllerTest.java index dcd627964017..11fa36cd1762 100644 --- a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/CategoryOptionComboControllerTest.java +++ b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/CategoryOptionComboControllerTest.java @@ -28,6 +28,7 @@ package org.hisp.dhis.webapi.controller; import static org.hisp.dhis.http.HttpAssertions.assertStatus; +import static org.hisp.dhis.test.webapi.Assertions.assertWebMessage; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -43,11 +44,13 @@ import org.hisp.dhis.http.HttpStatus; import org.hisp.dhis.jsontree.JsonArray; import org.hisp.dhis.jsontree.JsonList; +import org.hisp.dhis.jsontree.JsonMixed; import org.hisp.dhis.jsontree.JsonObject; import org.hisp.dhis.test.webapi.H2ControllerIntegrationTestBase; import org.hisp.dhis.test.webapi.json.domain.JsonCategoryOptionCombo; import org.hisp.dhis.test.webapi.json.domain.JsonErrorReport; import org.hisp.dhis.test.webapi.json.domain.JsonIdentifiableObject; +import org.hisp.dhis.test.webapi.json.domain.JsonWebMessage; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -74,11 +77,11 @@ void setUp() { categoryService.addCategoryOption(catOptC); CategoryOptionCombo cocA = - createCategoryOptionCombo("CatOptCombo A", "CocUid0001", catComboA, catOptA); + createCategoryOptionCombo("CatOptCombo A", "CocUid00001", catComboA, catOptA); CategoryOptionCombo cocB = - createCategoryOptionCombo("CatOptCombo B", "CocUid0002", catComboB, catOptB); + createCategoryOptionCombo("CatOptCombo B", "CocUid00002", catComboB, catOptB); CategoryOptionCombo cocC = - createCategoryOptionCombo("CatOptCombo C", "CocUid0003", catComboC, catOptC); + createCategoryOptionCombo("CatOptCombo C", "CocUid00003", catComboC, catOptC); categoryService.addCategoryOptionCombo(cocA); categoryService.addCategoryOptionCombo(cocB); categoryService.addCategoryOptionCombo(cocC); @@ -123,6 +126,116 @@ void catOptionCombosExcludingDefaultTest() { catOptionComboNames.contains("default"), "default catOptionCombo is not in payload"); } + @Test + @DisplayName("Invalid merge with source and target missing") + void testInvalidMerge() { + JsonMixed mergeResponse = + POST( + "/categoryOptionCombos/merge", + """ + { + "sources": ["Uid00000010"], + "target": "Uid00000012", + "deleteSources": true, + "dataMergeStrategy": "DISCARD" + }""") + .content(HttpStatus.CONFLICT); + assertEquals("Conflict", mergeResponse.getString("httpStatus").string()); + assertEquals("WARNING", mergeResponse.getString("status").string()); + assertEquals( + "One or more errors occurred, please see full details in merge report.", + mergeResponse.getString("message").string()); + + JsonArray errors = + mergeResponse.getObject("response").getObject("mergeReport").getArray("mergeErrors"); + JsonObject error1 = errors.getObject(0); + JsonObject error2 = errors.getObject(1); + assertEquals( + "SOURCE CategoryOptionCombo does not exist: `Uid00000010`", + error1.getString("message").string()); + assertEquals( + "TARGET CategoryOptionCombo does not exist: `Uid00000012`", + error2.getString("message").string()); + } + + @Test + @DisplayName("invalid merge, missing required auth") + void testMergeNoAuth() { + switchToNewUser("noAuth", "NoAuth"); + JsonMixed mergeResponse = + POST( + "/categoryOptionCombos/merge", + """ + { + "sources": ["Uid00000010"], + "target": "Uid00000012", + "deleteSources": true, + "dataMergeStrategy": "DISCARD" + }""") + .content(HttpStatus.FORBIDDEN); + assertEquals("Forbidden", mergeResponse.getString("httpStatus").string()); + assertEquals("ERROR", mergeResponse.getString("status").string()); + assertEquals( + "Access is denied, requires one Authority from [F_CATEGORY_OPTION_COMBO_MERGE]", + mergeResponse.getString("message").string()); + } + + @Test + @DisplayName("invalid merge, missing dataMergeStrategy") + void mergeMissingDataMergeStrategyTest() { + JsonWebMessage validationErrorMsg = + assertWebMessage( + "Conflict", + 409, + "WARNING", + "One or more errors occurred, please see full details in merge report.", + POST( + "/categoryOptionCombos/merge", + """ + { + "sources": ["CocUid00001"], + "target": "CocUid00002", + "deleteSources": true + }""") + .content(HttpStatus.CONFLICT)); + + JsonErrorReport errorReport = + validationErrorMsg.find( + JsonErrorReport.class, error -> error.getErrorCode() == ErrorCode.E1534); + assertNotNull(errorReport); + assertEquals( + "dataMergeStrategy field must be specified. With value `DISCARD` or `LAST_UPDATED`", + errorReport.getMessage()); + } + + @Test + @DisplayName("invalid merge, UID is for type other than CategoryOptionCombo") + void mergeIncorrectTypeTest() { + JsonWebMessage validationErrorMsg = + assertWebMessage( + "Conflict", + 409, + "WARNING", + "One or more errors occurred, please see full details in merge report.", + POST( + "/categoryOptionCombos/merge", + """ + { + "sources": ["bjDvmb4bfuf"], + "target": "CocUid00002", + "deleteSources": true, + "dataMergeStrategy": "DISCARD" + }""") + .content(HttpStatus.CONFLICT)); + + JsonErrorReport errorReport = + validationErrorMsg.find( + JsonErrorReport.class, error -> error.getErrorCode() == ErrorCode.E1533); + assertNotNull(errorReport); + assertEquals( + "SOURCE CategoryOptionCombo does not exist: `bjDvmb4bfuf`", errorReport.getMessage()); + } + @Test @DisplayName("Duplicate default category option combos should not be allowed") void catOptionCombosDuplicatedDefaultTest() { @@ -138,10 +251,10 @@ void catOptionCombosDuplicatedDefaultTest() { POST( "/categoryOptionCombos/", """ - { "name": "Not default", - "categoryOptions" : [{"id" : "%s"}], - "categoryCombo" : {"id" : "%s"} } - """ + { "name": "Not default", + "categoryOptions" : [{"id" : "%s"}], + "categoryCombo" : {"id" : "%s"} } + """ .formatted(defaultCatOptionComboOptions, defaultCatOptionComboCatComboId)) .content(HttpStatus.CONFLICT); diff --git a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/dataintegrity/DataIntegrityUserRolesNoAuthorities.java b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/dataintegrity/DataIntegrityUserRolesNoAuthorities.java index 4861ab9ba514..c37972fe7a66 100644 --- a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/dataintegrity/DataIntegrityUserRolesNoAuthorities.java +++ b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/dataintegrity/DataIntegrityUserRolesNoAuthorities.java @@ -28,8 +28,17 @@ package org.hisp.dhis.webapi.controller.dataintegrity; import static org.hisp.dhis.http.HttpAssertions.assertStatus; +import static org.junit.jupiter.api.Assertions.assertEquals; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; import org.hisp.dhis.http.HttpStatus; +import org.hisp.dhis.jsontree.JsonList; +import org.hisp.dhis.jsontree.JsonObject; +import org.hisp.dhis.jsontree.JsonString; +import org.hisp.dhis.test.webapi.json.domain.JsonUserRole; import org.junit.jupiter.api.Test; class DataIntegrityUserRolesNoAuthorities extends AbstractDataIntegrityIntegrationTest { @@ -48,10 +57,22 @@ void testUserRolesNoAuthorities() { assertStatus( HttpStatus.CREATED, POST("/userRoles", "{ 'name': 'Good role', 'authorities': ['F_DATAVALUE_ADD'] }")); - // Note that two user roles already exist due to the setup in the - // AbstractDataIntegrityIntegrationTest class - // Thus there should be 4 roles total. Only the Empty role should be flagged. + + JsonObject content = GET("/userRoles?fields=id,authorities").content(); + JsonList userRolesInSystem = content.getList("userRoles", JsonUserRole.class); + assertEquals(3, userRolesInSystem.size()); + + List authorityCount = + userRolesInSystem.stream() + .map(userRole -> userRole.getList("authorities", JsonString.class).size()) + .toList(); + + // Two of the roles have no authorities, one has one authority. + assertEquals(Set.of(0, 1), new HashSet<>(authorityCount)); + assertEquals(1, Collections.frequency(authorityCount, 0)); + assertEquals(2, Collections.frequency(authorityCount, 1)); + assertHasDataIntegrityIssues( - DETAILS_ID_TYPE, CHECK_NAME, 25, userRoleUid, "Empty role", null, true); + DETAILS_ID_TYPE, CHECK_NAME, 33, userRoleUid, "Empty role", null, true); } } diff --git a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/dataintegrity/DataIntegrityUserRolesNoUsers.java b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/dataintegrity/DataIntegrityUserRolesNoUsers.java index 3cf64296ddde..c4a9a6995e5e 100644 --- a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/dataintegrity/DataIntegrityUserRolesNoUsers.java +++ b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/dataintegrity/DataIntegrityUserRolesNoUsers.java @@ -56,6 +56,6 @@ void testUserRolesNoUsers() { JsonDataIntegritySummary summary = getSummary(CHECK_NAME); assertEquals(1, summary.getCount()); assertHasDataIntegrityIssues( - DETAILS_ID_TYPE, CHECK_NAME, 33, userRoleUid, "Test role", null, true); + DETAILS_ID_TYPE, CHECK_NAME, 50, userRoleUid, "Test role", null, true); } } diff --git a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/dataintegrity/DataIntegrityUsersWithAllAuthorityControllerTest.java b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/dataintegrity/DataIntegrityUsersWithAllAuthorityControllerTest.java new file mode 100644 index 000000000000..0c33693299a7 --- /dev/null +++ b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/dataintegrity/DataIntegrityUsersWithAllAuthorityControllerTest.java @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2004-2022, 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: + * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 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. + * Neither the name of the HISP project nor the names of its contributors may + * be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package org.hisp.dhis.webapi.controller.dataintegrity; + +import static org.hisp.dhis.http.HttpAssertions.assertStatus; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import org.hisp.dhis.http.HttpStatus; +import org.hisp.dhis.user.User; +import org.junit.jupiter.api.Test; + +/** + * Integrity check to identify users who have the ALL authority. {@see + * dhis-2/dhis-services/dhis-service-administration/src/main/resources/data-integrity-checks/users/users_with_all_authority.yaml} + * + * @author Jason P. Pickering + */ +class DataIntegrityUsersWithAllAuthorityControllerTest + extends AbstractDataIntegrityIntegrationTest { + + private static final String CHECK_NAME = "users_with_all_authority"; + + private static final String DETAILS_ID_TYPE = "users"; + + @Test + void testCanIdentifyUsersWithAllAuthority() { + + String orgunitAUid = + assertStatus( + HttpStatus.CREATED, + POST( + "/organisationUnits", + "{ 'name': 'Fish District', 'shortName': 'Fish District', 'openingDate' : '2022-01-01'}")); + + String userRoleUidA = + assertStatus( + HttpStatus.CREATED, + POST("/userRoles", "{ 'name': 'ALL role', 'authorities': ['ALL'] }")); + + assertStatus( + HttpStatus.CREATED, + POST( + "/users", + "{ 'username': 'bobbytables' , 'password': 'District123+', 'firstName': 'Bobby', 'surname': 'Tables', 'organisationUnits' : [{'id' : '" + + orgunitAUid + + "'}], 'dataViewOrganisationUnits' : [{'id' : '" + + orgunitAUid + + "'}], 'userRoles' : [{'id' : '" + + userRoleUidA + + "'}]}")); + + // Note that there is already one user which exists due to the overall test setup, thus, two + // users in total. + List allUsers = userService.getAllUsers(); + assertEquals(2, allUsers.size()); + Set userNames = new HashSet<>(); + for (User user : allUsers) { + userNames.add(user.getUsername()); + } + Set userUids = new HashSet<>(); + for (User user : allUsers) { + userUids.add(user.getUid()); + } + Set userComments = new HashSet<>(); + // Each user should be active, so just create a set of "Active" for each user + allUsers.forEach(user -> userComments.add("Active")); + + // Add a non-ALL authority user + + String userRoleUidB = + assertStatus( + HttpStatus.CREATED, + POST("/userRoles", "{ 'name': 'Not all role', 'authorities': ['F_DATAVALUE_ADD'] }")); + + assertStatus( + HttpStatus.CREATED, + POST( + "/users", + "{ 'username': 'bobbytables2' , 'password': 'District123+', 'firstName': 'Bobby', 'surname': 'Tables', 'organisationUnits' : [{'id' : '" + + orgunitAUid + + "'}], 'dataViewOrganisationUnits' : [{'id' : '" + + orgunitAUid + + "'}], 'userRoles' : [{'id' : '" + + userRoleUidB + + "'}]}")); + + // Note the expected percentage is 2/3 = 66% + assertHasDataIntegrityIssues( + DETAILS_ID_TYPE, CHECK_NAME, 66, userUids, userNames, userComments, true); + } +} diff --git a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/dataintegrity/DataIntegrityUsersWithNoRoleControllerTest.java b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/dataintegrity/DataIntegrityUsersWithNoRoleControllerTest.java index b2ce3e5ebed1..d218e792b956 100644 --- a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/dataintegrity/DataIntegrityUsersWithNoRoleControllerTest.java +++ b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/dataintegrity/DataIntegrityUsersWithNoRoleControllerTest.java @@ -84,7 +84,6 @@ void testCanFlagUserWithNoRoles() { @Test void testDoNotFlagUsersWithRoles() { - assertHasNoDataIntegrityIssues(DETAILS_ID_TYPE, CHECK_NAME, true); } } diff --git a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/JsonAssertions.java b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/JsonAssertions.java index 44dce4e051b1..b006553f812e 100644 --- a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/JsonAssertions.java +++ b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/JsonAssertions.java @@ -35,8 +35,10 @@ import java.util.Collection; import java.util.HashSet; import java.util.List; +import java.util.Optional; import java.util.Set; import java.util.function.Function; +import java.util.function.Predicate; import org.hisp.dhis.jsontree.JsonArray; import org.hisp.dhis.jsontree.JsonList; import org.hisp.dhis.jsontree.JsonObject; @@ -171,6 +173,24 @@ public static void assertHasMember(JsonObject json, String name) { assertTrue(json.has(name), String.format("member \"%s\" should be in %s", name, json)); } + /** + * Asserts that the actual list contains an element matching the predicate. If the element is + * found, it is returned. + * + * @param actual the list to search + * @param predicate the predicate to match the element + * @param messageSubject the subject of the message in case the element is not found + * @return the element that matches the predicate + * @param the type of the elements in the list + */ + public static T assertContains( + JsonList actual, Predicate predicate, String messageSubject) { + Optional element = actual.stream().filter(predicate).findFirst(); + assertTrue( + element.isPresent(), () -> String.format("%s not found in %s", messageSubject, actual)); + return element.get(); + } + public static void assertContainsAll( Collection expected, JsonList actual, Function toValue) { assertFalse( diff --git a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/enrollment/EnrollmentsExportControllerTest.java b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/enrollment/EnrollmentsExportControllerTest.java index 75299997d412..c1db357afa70 100644 --- a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/enrollment/EnrollmentsExportControllerTest.java +++ b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/enrollment/EnrollmentsExportControllerTest.java @@ -27,254 +27,269 @@ */ package org.hisp.dhis.webapi.controller.tracker.export.enrollment; +import static org.hisp.dhis.test.utils.Assertions.assertNotEmpty; import static org.hisp.dhis.test.utils.Assertions.assertStartsWith; +import static org.hisp.dhis.webapi.controller.tracker.JsonAssertions.assertContains; import static org.hisp.dhis.webapi.controller.tracker.JsonAssertions.assertHasMember; import static org.hisp.dhis.webapi.controller.tracker.JsonAssertions.assertHasNoMember; import static org.hisp.dhis.webapi.controller.tracker.JsonAssertions.assertHasOnlyMembers; +import static org.junit.jupiter.api.Assertions.assertAll; 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.assertTrue; -import java.util.Date; +import java.io.IOException; +import java.util.ArrayList; import java.util.List; -import java.util.Set; -import org.hisp.dhis.category.CategoryOptionCombo; -import org.hisp.dhis.category.CategoryService; -import org.hisp.dhis.common.CodeGenerator; +import java.util.Map; +import java.util.function.Supplier; +import org.hisp.dhis.common.IdentifiableObject; import org.hisp.dhis.common.IdentifiableObjectManager; import org.hisp.dhis.common.ValueType; -import org.hisp.dhis.dataelement.DataElement; +import org.hisp.dhis.dxf2.metadata.objectbundle.ObjectBundle; +import org.hisp.dhis.dxf2.metadata.objectbundle.ObjectBundleMode; +import org.hisp.dhis.dxf2.metadata.objectbundle.ObjectBundleParams; +import org.hisp.dhis.dxf2.metadata.objectbundle.ObjectBundleService; +import org.hisp.dhis.dxf2.metadata.objectbundle.ObjectBundleValidationService; +import org.hisp.dhis.dxf2.metadata.objectbundle.feedback.ObjectBundleValidationReport; import org.hisp.dhis.eventdatavalue.EventDataValue; import org.hisp.dhis.http.HttpStatus; +import org.hisp.dhis.importexport.ImportStrategy; import org.hisp.dhis.jsontree.JsonList; -import org.hisp.dhis.note.Note; -import org.hisp.dhis.organisationunit.OrganisationUnit; import org.hisp.dhis.program.Enrollment; -import org.hisp.dhis.program.EnrollmentStatus; import org.hisp.dhis.program.Event; -import org.hisp.dhis.program.Program; -import org.hisp.dhis.program.ProgramStage; import org.hisp.dhis.relationship.Relationship; -import org.hisp.dhis.relationship.RelationshipEntity; -import org.hisp.dhis.relationship.RelationshipItem; -import org.hisp.dhis.relationship.RelationshipType; -import org.hisp.dhis.security.acl.AccessStringHelper; -import org.hisp.dhis.test.webapi.H2ControllerIntegrationTestBase; -import org.hisp.dhis.trackedentity.TrackedEntity; +import org.hisp.dhis.render.RenderFormat; +import org.hisp.dhis.render.RenderService; +import org.hisp.dhis.test.webapi.PostgresControllerIntegrationTestBase; import org.hisp.dhis.trackedentity.TrackedEntityAttribute; -import org.hisp.dhis.trackedentity.TrackedEntityType; -import org.hisp.dhis.trackedentityattributevalue.TrackedEntityAttributeValue; +import org.hisp.dhis.tracker.imports.TrackerImportParams; +import org.hisp.dhis.tracker.imports.TrackerImportService; +import org.hisp.dhis.tracker.imports.domain.TrackerObjects; +import org.hisp.dhis.tracker.imports.report.ImportReport; +import org.hisp.dhis.tracker.imports.report.Status; +import org.hisp.dhis.tracker.imports.report.ValidationReport; import org.hisp.dhis.user.User; import org.hisp.dhis.webapi.controller.tracker.JsonAttribute; +import org.hisp.dhis.webapi.controller.tracker.JsonDataValue; import org.hisp.dhis.webapi.controller.tracker.JsonEnrollment; import org.hisp.dhis.webapi.controller.tracker.JsonEvent; import org.hisp.dhis.webapi.controller.tracker.JsonNote; import org.hisp.dhis.webapi.controller.tracker.JsonRelationship; -import org.hisp.dhis.webapi.controller.tracker.JsonRelationshipItem; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.io.ClassPathResource; import org.springframework.transaction.annotation.Transactional; @Transactional -class EnrollmentsExportControllerTest extends H2ControllerIntegrationTestBase { +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class EnrollmentsExportControllerTest extends PostgresControllerIntegrationTestBase { - private static final String ATTRIBUTE_VALUE = "value"; + @Autowired private RenderService renderService; - @Autowired private IdentifiableObjectManager manager; - - @Autowired private CategoryService categoryService; - - private CategoryOptionCombo coc; - - private OrganisationUnit orgUnit; - - private User owner; + @Autowired private ObjectBundleService objectBundleService; - private Program program; + @Autowired private ObjectBundleValidationService objectBundleValidationService; - private TrackedEntity te; + @Autowired private TrackerImportService trackerImportService; - private Enrollment enrollment; - - private TrackedEntityAttribute tea; + @Autowired private IdentifiableObjectManager manager; - private Relationship relationship; + private User importUser; + + protected ObjectBundle setUpMetadata(String path) throws IOException { + Map, List> metadata = + renderService.fromMetadata(new ClassPathResource(path).getInputStream(), RenderFormat.JSON); + ObjectBundleParams params = new ObjectBundleParams(); + params.setObjectBundleMode(ObjectBundleMode.COMMIT); + params.setImportStrategy(ImportStrategy.CREATE); + params.setObjects(metadata); + ObjectBundle bundle = objectBundleService.create(params); + assertNoErrors(objectBundleValidationService.validate(bundle)); + objectBundleService.commit(bundle); + return bundle; + } - private ProgramStage programStage; + protected TrackerObjects fromJson(String path) throws IOException { + return renderService.fromJson( + new ClassPathResource(path).getInputStream(), TrackerObjects.class); + } - private Event event; + @BeforeAll + void setUp() throws IOException { + setUpMetadata("tracker/simple_metadata.json"); - private DataElement dataElement; + importUser = userService.getUser("tTgjgobT1oS"); + injectSecurityContextUser(importUser); - private TrackedEntityAttributeValue trackedEntityAttributeValue; + TrackerImportParams params = TrackerImportParams.builder().build(); + assertNoErrors( + trackerImportService.importTracker(params, fromJson("tracker/event_and_enrollment.json"))); - private EventDataValue eventDataValue; + manager.flush(); + manager.clear(); + } @BeforeEach - void setUp() { - owner = makeUser("o"); - manager.save(owner, false); - - coc = categoryService.getDefaultCategoryOptionCombo(); - - orgUnit = createOrganisationUnit('A'); - manager.save(orgUnit); - - User user = createUserWithId("tester", CodeGenerator.generateUid()); - user.addOrganisationUnit(orgUnit); - user.setTeiSearchOrganisationUnits(Set.of(orgUnit)); - this.userService.updateUser(user); - - program = createProgram('A'); - manager.save(program); - - TrackedEntityType trackedEntityType = createTrackedEntityType('A'); - manager.save(trackedEntityType); - - tea = createTrackedEntityAttribute('A'); - tea.getSharing().setOwner(owner); - manager.save(tea, false); - - te = createTrackedEntity(orgUnit); - te.setTrackedEntityType(trackedEntityType); - manager.save(te); - - trackedEntityAttributeValue = new TrackedEntityAttributeValue(); - trackedEntityAttributeValue.setAttribute(tea); - trackedEntityAttributeValue.setTrackedEntity(te); - trackedEntityAttributeValue.setStoredBy("user"); - trackedEntityAttributeValue.setValue(ATTRIBUTE_VALUE); - te.setTrackedEntityAttributeValues(Set.of(trackedEntityAttributeValue)); - manager.update(te); - - program.setProgramAttributes(List.of(createProgramTrackedEntityAttribute(program, tea))); - - programStage = createProgramStage('A', program); - manager.save(programStage); - - enrollment = enrollment(te); - event = event(); - enrollment.setEvents(Set.of(event)); - manager.update(enrollment); - - manager.save(relationship(enrollment, te)); + void setUpUser() { + switchContextToUser(importUser); } @Test void getEnrollmentById() { - JsonEnrollment enrollment = - GET("/tracker/enrollments/{id}", this.enrollment.getUid()) + Enrollment enrollment = get(Enrollment.class, "TvctPPhpD8z"); + + JsonEnrollment jsonEnrollment = + GET("/tracker/enrollments/{id}", enrollment.getUid()) .content(HttpStatus.OK) .as(JsonEnrollment.class); - assertDefaultResponse(enrollment); + assertDefaultResponse(enrollment, jsonEnrollment); } @Test void getEnrollmentByIdWithFields() { - JsonEnrollment enrollment = - GET("/tracker/enrollments/{id}?fields=orgUnit,status", this.enrollment.getUid()) + Enrollment enrollment = get(Enrollment.class, "TvctPPhpD8z"); + + JsonEnrollment jsonEnrollment = + GET("/tracker/enrollments/{id}?fields=orgUnit,status", enrollment.getUid()) .content(HttpStatus.OK) .as(JsonEnrollment.class); - assertHasOnlyMembers(enrollment, "orgUnit", "status"); - assertEquals(this.enrollment.getOrganisationUnit().getUid(), enrollment.getOrgUnit()); - assertEquals(this.enrollment.getStatus().toString(), enrollment.getStatus()); + assertHasOnlyMembers(jsonEnrollment, "orgUnit", "status"); + assertEquals(enrollment.getOrganisationUnit().getUid(), jsonEnrollment.getOrgUnit()); + assertEquals(enrollment.getStatus().toString(), jsonEnrollment.getStatus()); } @Test void getEnrollmentByIdWithNotes() { - enrollment.setNotes(List.of(note("oqXG28h988k", "my notes", owner.getUid()))); + Enrollment enrollment = get(Enrollment.class, "TvctPPhpD8z"); + assertNotEmpty(enrollment.getNotes(), "test expects an enrollment with notes"); - JsonEnrollment enrollment = - GET("/tracker/enrollments/{uid}?fields=notes", this.enrollment.getUid()) + JsonEnrollment jsonEnrollment = + GET("/tracker/enrollments/{uid}?fields=notes", enrollment.getUid()) .content(HttpStatus.OK) .as(JsonEnrollment.class); - JsonNote note = enrollment.getNotes().get(0); - assertEquals("oqXG28h988k", note.getNote()); - assertEquals("my notes", note.getValue()); - assertEquals(owner.getUid(), note.getStoredBy()); + JsonNote note = jsonEnrollment.getNotes().get(0); + assertEquals("f9423652692", note.getNote()); + assertEquals("enrollment comment value", note.getValue()); } @Test void getEnrollmentByIdWithAttributes() { - JsonEnrollment enrollment = - GET("/tracker/enrollments/{id}?fields=attributes", this.enrollment.getUid()) + Enrollment enrollment = get(Enrollment.class, "TvctPPhpD8z"); + assertNotEmpty( + enrollment.getTrackedEntity().getTrackedEntityAttributeValues(), + "test expects an enrollment with attribute values"); + TrackedEntityAttribute ptea = get(TrackedEntityAttribute.class, "dIVt4l5vIOa"); + + JsonEnrollment jsonEnrollment = + GET("/tracker/enrollments/{id}?fields=attributes", enrollment.getUid()) .content(HttpStatus.OK) .as(JsonEnrollment.class); - assertHasOnlyMembers(enrollment, "attributes"); - JsonAttribute attribute = enrollment.getAttributes().get(0); - assertEquals(tea.getUid(), attribute.getAttribute()); - TrackedEntityAttribute expected = trackedEntityAttributeValue.getAttribute(); - assertEquals(trackedEntityAttributeValue.getValue(), attribute.getValue()); - assertEquals(expected.getValueType().toString(), attribute.getValueType()); + assertHasOnlyMembers(jsonEnrollment, "attributes"); + JsonAttribute attribute = jsonEnrollment.getAttributes().get(0); + assertEquals(ptea.getUid(), attribute.getAttribute()); + assertEquals("Frank PTEA", attribute.getValue()); + assertEquals(ValueType.TEXT.name(), attribute.getValueType()); assertHasMember(attribute, "createdAt"); assertHasMember(attribute, "updatedAt"); assertHasMember(attribute, "displayName"); assertHasMember(attribute, "code"); - assertHasMember(attribute, "storedBy"); } @Test void getEnrollmentByIdWithRelationshipsFields() { - JsonList relationships = + Relationship relationship = get(Relationship.class, "p53a6314631"); + assertNotNull( + relationship.getTo().getEnrollment(), + "test expects relationship to have a 'to' enrollment"); + Enrollment enrollment = relationship.getTo().getEnrollment(); + + JsonList jsonRelationships = GET("/tracker/enrollments/{id}?fields=relationships", enrollment.getUid()) .content(HttpStatus.OK) .getList("relationships", JsonRelationship.class); - JsonRelationship jsonRelationship = relationships.get(0); - assertEquals(relationship.getUid(), jsonRelationship.getRelationship()); - - JsonRelationshipItem.JsonEnrollment enrollment = jsonRelationship.getFrom().getEnrollment(); - assertEquals(relationship.getFrom().getEnrollment().getUid(), enrollment.getEnrollment()); - assertEquals( - relationship.getFrom().getEnrollment().getTrackedEntity().getUid(), - enrollment.getTrackedEntity()); - - JsonRelationshipItem.JsonTrackedEntity trackedEntity = - jsonRelationship.getTo().getTrackedEntity(); - assertEquals( - relationship.getTo().getTrackedEntity().getUid(), trackedEntity.getTrackedEntity()); - - assertHasMember(jsonRelationship, "relationshipName"); - assertHasMember(jsonRelationship, "relationshipType"); - assertHasMember(jsonRelationship, "createdAt"); - assertHasMember(jsonRelationship, "updatedAt"); - assertHasMember(jsonRelationship, "bidirectional"); + JsonRelationship jsonRelationship = + assertContains( + jsonRelationships, + re -> relationship.getUid().equals(re.getRelationship()), + relationship.getUid()); + + assertAll( + "relationship JSON", + () -> + assertEquals( + relationship.getFrom().getTrackedEntity().getUid(), + jsonRelationship.getFrom().getTrackedEntity().getTrackedEntity()), + () -> + assertEquals( + relationship.getTo().getEnrollment().getUid(), + jsonRelationship.getTo().getEnrollment().getEnrollment()), + () -> assertHasMember(jsonRelationship, "relationshipName"), + () -> assertHasMember(jsonRelationship, "relationshipType"), + () -> assertHasMember(jsonRelationship, "createdAt"), + () -> assertHasMember(jsonRelationship, "updatedAt"), + () -> assertHasMember(jsonRelationship, "bidirectional")); } @Test void getEnrollmentByIdWithEventsFields() { - JsonList events = - GET("/tracker/enrollments/{id}?fields=events", enrollment.getUid()) + Event event = get(Event.class, "pTzf9KYMk72"); + assertNotNull(event.getEnrollment(), "test expects an event with an enrollment"); + assertNotEmpty(event.getEventDataValues(), "test expects an event with data values"); + EventDataValue eventDataValue = event.getEventDataValues().iterator().next(); + + JsonList jsonEvents = + GET("/tracker/enrollments/{id}?fields=events", event.getEnrollment().getUid()) .content(HttpStatus.OK) .getList("events", JsonEvent.class); - JsonEvent event = events.get(0); - assertEquals(this.event.getUid(), event.getEvent()); - assertEquals(enrollment.getUid(), event.getEnrollment()); - assertEquals(te.getUid(), event.getTrackedEntity()); - assertEquals(dataElement.getUid(), event.getDataValues().get(0).getDataElement()); - assertEquals(eventDataValue.getValue(), event.getDataValues().get(0).getValue()); - assertEquals(program.getUid(), event.getProgram()); - - assertHasMember(event, "status"); - assertHasMember(event, "followUp"); - assertHasMember(event, "followup"); - assertEquals(program.getUid(), event.getProgram()); - assertEquals(orgUnit.getUid(), event.getOrgUnit()); - assertFalse(event.getDeleted()); + JsonEvent jsonEvent = jsonEvents.get(0); + assertAll( + "event JSON", + () -> assertEquals(event.getUid(), jsonEvent.getEvent()), + () -> assertEquals(event.getEnrollment().getUid(), jsonEvent.getEnrollment()), + () -> + assertEquals( + event.getEnrollment().getTrackedEntity().getUid(), jsonEvent.getTrackedEntity()), + () -> assertEquals(event.getProgramStage().getProgram().getUid(), jsonEvent.getProgram()), + () -> assertEquals(event.getOrganisationUnit().getUid(), jsonEvent.getOrgUnit()), + () -> { + JsonDataValue jsonDataValue = + assertContains( + jsonEvent.getDataValues(), + dv -> eventDataValue.getDataElement().equals(dv.getDataElement()), + eventDataValue.getDataElement()); + assertEquals( + eventDataValue.getValue(), + jsonDataValue.getValue(), + "data value for data element " + eventDataValue.getDataElement()); + }, + () -> assertHasMember(jsonEvent, "status"), + () -> assertHasMember(jsonEvent, "followUp"), + () -> assertHasMember(jsonEvent, "followup"), + () -> assertEquals(event.isDeleted(), jsonEvent.getDeleted())); } @Test void getEnrollmentByIdWithExcludedFields() { + Event event = get(Event.class, "pTzf9KYMk72"); + assertNotNull(event.getEnrollment(), "test expects an event with an enrollment"); + assertNotNull( + event.getRelationshipItems(), "test expects an event with at least one relationship"); + assertTrue( (GET( "/tracker/enrollments/{id}?fields=!attributes,!relationships,!events", - enrollment.getUid()) + event.getEnrollment().getUid()) .content(HttpStatus.OK)) .isEmpty()); } @@ -295,89 +310,66 @@ void getEnrollmentsFailsIfGivenEnrollmentAndEnrollmentsParameters() { .getMessage()); } - private Event event() { - Event eventA = new Event(enrollment, programStage, enrollment.getOrganisationUnit(), coc); - eventA.setAutoFields(); - - eventDataValue = new EventDataValue(); - eventDataValue.setValue("value"); - dataElement = createDataElement('A'); - dataElement.setValueType(ValueType.TEXT); - manager.save(dataElement); - eventDataValue.setDataElement(dataElement.getUid()); - Set eventDataValues = Set.of(eventDataValue); - eventA.setEventDataValues(eventDataValues); - manager.save(eventA); - return eventA; + private void assertDefaultResponse(Enrollment expected, JsonEnrollment actual) { + assertFalse(actual.isEmpty()); + assertEquals(expected.getUid(), actual.getEnrollment()); + assertEquals(expected.getTrackedEntity().getUid(), actual.getTrackedEntity()); + assertEquals(expected.getProgram().getUid(), actual.getProgram()); + assertEquals(expected.getStatus().name(), actual.getStatus()); + assertEquals(expected.getOrganisationUnit().getUid(), actual.getOrgUnit()); + assertEquals(expected.getFollowup(), actual.getBoolean("followUp").bool()); + assertEquals(expected.isDeleted(), actual.getBoolean("deleted").bool()); + assertHasMember(actual, "enrolledAt"); + assertHasMember(actual, "occurredAt"); + assertHasMember(actual, "createdAt"); + assertHasMember(actual, "createdAtClient"); + assertHasMember(actual, "updatedAt"); + assertHasMember(actual, "notes"); + assertHasNoMember(actual, "relationships"); + assertHasNoMember(actual, "events"); + assertHasNoMember(actual, "attributes"); } - private Relationship relationship(Enrollment from, TrackedEntity to) { - relationship = new Relationship(); - - RelationshipItem fromItem = new RelationshipItem(); - fromItem.setEnrollment(from); - from.getRelationshipItems().add(fromItem); - relationship.setFrom(fromItem); - fromItem.setRelationship(relationship); - - RelationshipItem toItem = new RelationshipItem(); - toItem.setTrackedEntity(to); - to.getRelationshipItems().add(toItem); - relationship.setTo(toItem); - toItem.setRelationship(relationship); - - RelationshipType type = createRelationshipType('A'); - type.getFromConstraint().setRelationshipEntity(RelationshipEntity.PROGRAM_INSTANCE); - type.getToConstraint().setRelationshipEntity(RelationshipEntity.TRACKED_ENTITY_INSTANCE); - type.getSharing().setPublicAccess(AccessStringHelper.DEFAULT); - manager.save(type, false); - - relationship.setRelationshipType(type); - relationship.setKey(type.getUid()); - relationship.setInvertedKey(type.getUid()); - relationship.setAutoFields(); - - manager.save(relationship, false); - return relationship; + private T get(Class type, String uid) { + T t = manager.get(type, uid); + assertNotNull( + t, + () -> + String.format( + "'%s' with uid '%s' should have been created", type.getSimpleName(), uid)); + return t; } - private void assertDefaultResponse(JsonEnrollment enrollment) { - assertFalse(enrollment.isEmpty()); - assertEquals(this.enrollment.getUid(), enrollment.getEnrollment()); - assertEquals(te.getUid(), enrollment.getTrackedEntity()); - assertEquals(program.getUid(), enrollment.getProgram()); - assertEquals("COMPLETED", enrollment.getStatus()); - assertEquals(orgUnit.getUid(), enrollment.getOrgUnit()); - assertTrue(enrollment.getBoolean("followUp").bool()); - assertFalse(enrollment.getBoolean("deleted").bool()); - assertHasMember(enrollment, "enrolledAt"); - assertHasMember(enrollment, "occurredAt"); - assertHasMember(enrollment, "createdAt"); - assertHasMember(enrollment, "createdAtClient"); - assertHasMember(enrollment, "updatedAt"); - assertHasMember(enrollment, "notes"); - assertHasNoMember(enrollment, "relationships"); - assertHasNoMember(enrollment, "events"); - assertHasNoMember(enrollment, "attributes"); + public static void assertNoErrors(ImportReport report) { + assertNotNull(report); + assertEquals( + Status.OK, + report.getStatus(), + errorMessage( + "Expected import with status OK, instead got:%n", report.getValidationReport())); } - private Enrollment enrollment(TrackedEntity te) { - Enrollment enrollment = new Enrollment(program, te, orgUnit); - enrollment.setAutoFields(); - enrollment.setEnrollmentDate(new Date()); - enrollment.setOccurredDate(new Date()); - enrollment.setStatus(EnrollmentStatus.COMPLETED); - enrollment.setFollowup(true); - manager.save(enrollment, false); - te.setEnrollments(Set.of(enrollment)); - manager.save(te, false); - return enrollment; + private static Supplier errorMessage(String errorTitle, ValidationReport report) { + return () -> { + StringBuilder msg = new StringBuilder(errorTitle); + report + .getErrors() + .forEach( + e -> { + msg.append(e.getErrorCode()); + msg.append(": "); + msg.append(e.getMessage()); + msg.append('\n'); + }); + return msg.toString(); + }; } - private Note note(String uid, String value, String storedBy) { - Note note = new Note(value, storedBy); - note.setUid(uid); - manager.save(note, false); - return note; + public static void assertNoErrors(ObjectBundleValidationReport report) { + assertNotNull(report); + List errors = new ArrayList<>(); + report.forEachErrorReport(err -> errors.add(err.toString())); + assertFalse( + report.hasErrorReports(), String.format("Expected no errors, instead got: %s%n", errors)); } } diff --git a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/relationship/RelationshipsExportControllerTest.java b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/relationship/RelationshipsExportControllerTest.java index b10ce8864dcd..9261a496081b 100644 --- a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/relationship/RelationshipsExportControllerTest.java +++ b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/relationship/RelationshipsExportControllerTest.java @@ -27,6 +27,7 @@ */ package org.hisp.dhis.webapi.controller.tracker.export.relationship; +import static org.hisp.dhis.test.utils.Assertions.assertIsEmpty; import static org.hisp.dhis.test.utils.Assertions.assertStartsWith; import static org.hisp.dhis.webapi.controller.tracker.JsonAssertions.assertContainsAll; import static org.hisp.dhis.webapi.controller.tracker.JsonAssertions.assertEnrollmentWithinRelationship; @@ -652,7 +653,11 @@ void getRelationshipsByTrackedEntityWithEnrollments() { } @Test - void getRelationshipsByTrackedEntityAndEnrollmentWithAttributes() { + void getRelationshipsByTrackedEntityAndEnrollmentWithAttributesIsEmpty() { + // Tracked entity attribute values are owned by the tracked entity and only mapped onto the + // enrollment on export. Program tracked entity attributes are only returned by the underlying + // TE service if a program is + // provided which is not possible on the relationship endpoint. TrackedEntity to = trackedEntity(orgUnit); to.setTrackedEntityAttributeValues( Set.of(attributeValue(tea, to, "12"), attributeValue(tea2, to, "24"))); @@ -689,13 +694,13 @@ void getRelationshipsByTrackedEntityAndEnrollmentWithAttributes() { JsonList enrollmentAttr = relationships.get(0).getFrom().getEnrollment().getAttributes(); - assertContainsAll(List.of(tea2.getUid()), enrollmentAttr, JsonAttribute::getAttribute); - assertContainsAll(List.of("24"), enrollmentAttr, JsonAttribute::getValue); + assertIsEmpty( + enrollmentAttr.toList(JsonAttribute::getAttribute), + "program attributes should not be returned as no program can be provided"); JsonList teAttributes = relationships.get(0).getTo().getTrackedEntity().getAttributes(); - assertContainsAll( - List.of(tea.getUid(), tea2.getUid()), teAttributes, JsonAttribute::getAttribute); - assertContainsAll(List.of("12", "24"), teAttributes, JsonAttribute::getValue); + assertContainsAll(List.of(tea.getUid()), teAttributes, JsonAttribute::getAttribute); + assertContainsAll(List.of("12"), teAttributes, JsonAttribute::getValue); } @Test diff --git a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/trackedentity/TrackedEntitiesExportControllerTest.java b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/trackedentity/TrackedEntitiesExportControllerTest.java index 078ad8c972d4..89cea37bf476 100644 --- a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/trackedentity/TrackedEntitiesExportControllerTest.java +++ b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/trackedentity/TrackedEntitiesExportControllerTest.java @@ -29,6 +29,10 @@ import static org.hisp.dhis.common.OrganisationUnitSelectionMode.ACCESSIBLE; import static org.hisp.dhis.http.HttpClientAdapter.Accept; +import static org.hisp.dhis.test.utils.Assertions.assertContains; +import static org.hisp.dhis.test.utils.Assertions.assertHasSize; +import static org.hisp.dhis.test.utils.Assertions.assertIsEmpty; +import static org.hisp.dhis.test.utils.Assertions.assertNotEmpty; import static org.hisp.dhis.test.utils.Assertions.assertStartsWith; import static org.hisp.dhis.webapi.controller.tracker.JsonAssertions.assertContainsAll; import static org.hisp.dhis.webapi.controller.tracker.JsonAssertions.assertFirstRelationship; @@ -41,68 +45,129 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.io.IOException; import java.time.LocalDate; +import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.Set; -import org.hisp.dhis.category.CategoryOptionCombo; -import org.hisp.dhis.category.CategoryService; +import java.util.function.Supplier; import org.hisp.dhis.common.CodeGenerator; +import org.hisp.dhis.common.IdentifiableObject; import org.hisp.dhis.common.IdentifiableObjectManager; import org.hisp.dhis.common.ValueType; -import org.hisp.dhis.dataelement.DataElement; -import org.hisp.dhis.eventdatavalue.EventDataValue; +import org.hisp.dhis.dxf2.metadata.objectbundle.ObjectBundle; +import org.hisp.dhis.dxf2.metadata.objectbundle.ObjectBundleMode; +import org.hisp.dhis.dxf2.metadata.objectbundle.ObjectBundleParams; +import org.hisp.dhis.dxf2.metadata.objectbundle.ObjectBundleService; +import org.hisp.dhis.dxf2.metadata.objectbundle.ObjectBundleValidationService; +import org.hisp.dhis.dxf2.metadata.objectbundle.feedback.ObjectBundleValidationReport; import org.hisp.dhis.feedback.ConflictException; import org.hisp.dhis.fileresource.FileResource; import org.hisp.dhis.fileresource.FileResourceService; import org.hisp.dhis.fileresource.FileResourceStorageStatus; import org.hisp.dhis.http.HttpStatus; +import org.hisp.dhis.importexport.ImportStrategy; import org.hisp.dhis.jsontree.JsonList; import org.hisp.dhis.organisationunit.OrganisationUnit; import org.hisp.dhis.program.Enrollment; import org.hisp.dhis.program.Event; import org.hisp.dhis.program.Program; import org.hisp.dhis.program.ProgramStage; -import org.hisp.dhis.program.UserInfoSnapshot; import org.hisp.dhis.relationship.Relationship; import org.hisp.dhis.relationship.RelationshipEntity; import org.hisp.dhis.relationship.RelationshipItem; import org.hisp.dhis.relationship.RelationshipType; +import org.hisp.dhis.render.RenderFormat; +import org.hisp.dhis.render.RenderService; import org.hisp.dhis.security.acl.AccessStringHelper; -import org.hisp.dhis.test.webapi.H2ControllerIntegrationTestBase; +import org.hisp.dhis.test.webapi.PostgresControllerIntegrationTestBase; import org.hisp.dhis.trackedentity.TrackedEntity; import org.hisp.dhis.trackedentity.TrackedEntityAttribute; import org.hisp.dhis.trackedentity.TrackedEntityType; import org.hisp.dhis.trackedentity.TrackedEntityTypeAttribute; import org.hisp.dhis.trackedentityattributevalue.TrackedEntityAttributeValue; +import org.hisp.dhis.tracker.imports.TrackerImportParams; +import org.hisp.dhis.tracker.imports.TrackerImportService; +import org.hisp.dhis.tracker.imports.domain.TrackerObjects; +import org.hisp.dhis.tracker.imports.report.ImportReport; +import org.hisp.dhis.tracker.imports.report.Status; +import org.hisp.dhis.tracker.imports.report.ValidationReport; import org.hisp.dhis.user.User; import org.hisp.dhis.user.sharing.UserAccess; import org.hisp.dhis.util.DateUtils; import org.hisp.dhis.webapi.controller.tracker.JsonAttribute; -import org.hisp.dhis.webapi.controller.tracker.JsonDataValue; import org.hisp.dhis.webapi.controller.tracker.JsonEnrollment; import org.hisp.dhis.webapi.controller.tracker.JsonEvent; import org.hisp.dhis.webapi.controller.tracker.JsonRelationship; import org.hisp.dhis.webapi.controller.tracker.JsonRelationshipItem; import org.hisp.dhis.webapi.controller.tracker.JsonTrackedEntity; import org.hisp.dhis.webapi.utils.ContextUtils; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.io.ClassPathResource; import org.springframework.transaction.annotation.Transactional; @Transactional -class TrackedEntitiesExportControllerTest extends H2ControllerIntegrationTestBase { +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class TrackedEntitiesExportControllerTest extends PostgresControllerIntegrationTestBase { // Used to generate unique chars for creating test objects like TEA, ... private static final String UNIQUE_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; - private static final String EVENT_OCCURRED_AT = "2023-03-23T12:23:00.000"; + + @Autowired private RenderService renderService; + + @Autowired private ObjectBundleService objectBundleService; + + @Autowired private ObjectBundleValidationService objectBundleValidationService; + + @Autowired private TrackerImportService trackerImportService; @Autowired private IdentifiableObjectManager manager; - @Autowired private FileResourceService fileResourceService; + private User importUser; + + protected ObjectBundle setUpMetadata(String path) throws IOException { + Map, List> metadata = + renderService.fromMetadata(new ClassPathResource(path).getInputStream(), RenderFormat.JSON); + ObjectBundleParams params = new ObjectBundleParams(); + params.setObjectBundleMode(ObjectBundleMode.COMMIT); + params.setImportStrategy(ImportStrategy.CREATE); + params.setObjects(metadata); + ObjectBundle bundle = objectBundleService.create(params); + assertNoErrors(objectBundleValidationService.validate(bundle)); + objectBundleService.commit(bundle); + return bundle; + } + + protected TrackerObjects fromJson(String path) throws IOException { + return renderService.fromJson( + new ClassPathResource(path).getInputStream(), TrackerObjects.class); + } + + @BeforeAll + void setUp() throws IOException { + setUpMetadata("tracker/simple_metadata.json"); - @Autowired private CategoryService categoryService; + importUser = userService.getUser("tTgjgobT1oS"); + injectSecurityContextUser(importUser); - private CategoryOptionCombo coc; + TrackerImportParams params = TrackerImportParams.builder().build(); + assertNoErrors( + trackerImportService.importTracker(params, fromJson("tracker/event_and_enrollment.json"))); + + manager.flush(); + manager.clear(); + } + + @BeforeEach + void setUpUser() { + switchContextToUser(importUser); + } + + @Autowired private FileResourceService fileResourceService; private OrganisationUnit orgUnit; @@ -114,8 +179,6 @@ class TrackedEntitiesExportControllerTest extends H2ControllerIntegrationTestBas private TrackedEntityType trackedEntityType; - private DataElement dataElement; - private User owner; private User user; @@ -126,11 +189,9 @@ class TrackedEntitiesExportControllerTest extends H2ControllerIntegrationTestBas private int uniqueAttributeCharCounter = 0; @BeforeEach - void setUp() { + void setUpToBeMigrated() { owner = makeUser("owner"); - coc = categoryService.getDefaultCategoryOptionCombo(); - orgUnit = createOrganisationUnit('A'); orgUnit.getSharing().setOwner(owner); manager.save(orgUnit, false); @@ -166,8 +227,6 @@ void setUp() { @Test void getTrackedEntitiesNeedsProgramOrType() { - injectSecurityContextUser(user); - assertEquals( "Either `program`, `trackedEntityType` or `trackedEntities` should be specified", GET("/tracker/trackedEntities").error(HttpStatus.BAD_REQUEST).getMessage()); @@ -175,8 +234,6 @@ void getTrackedEntitiesNeedsProgramOrType() { @Test void getTrackedEntitiesNeedsProgramOrTrackedEntityType() { - this.switchContextToUser(user); - assertEquals( "Either `program`, `trackedEntityType` or `trackedEntities` should be specified", GET("/tracker/trackedEntities?orgUnit={ou}", orgUnit.getUid()) @@ -187,7 +244,7 @@ void getTrackedEntitiesNeedsProgramOrTrackedEntityType() { @Test void shouldReturnEmptyListWhenGettingTrackedEntitiesWithNoMatchingParams() { LocalDate futureDate = LocalDate.now().plusYears(1); - JsonList instances = + JsonList trackedEntities = GET("/tracker/trackedEntities?trackedEntityType=" + trackedEntityType.getUid() + "&ouMode=ALL" @@ -197,13 +254,12 @@ void shouldReturnEmptyListWhenGettingTrackedEntitiesWithNoMatchingParams() { .content(HttpStatus.OK) .getList("trackedEntities", JsonTrackedEntity.class); - assertEquals(0, instances.size()); + assertEquals(0, trackedEntities.size()); } @Test void getTrackedEntityById() { - TrackedEntity te = trackedEntity(); - this.switchContextToUser(user); + TrackedEntity te = get(TrackedEntity.class, "QS6w44flWAf"); JsonTrackedEntity json = GET("/tracker/trackedEntities/{id}", te.getUid()) @@ -225,8 +281,7 @@ void getTrackedEntityById() { @Test void getTrackedEntityByIdWithFields() { - TrackedEntity te = trackedEntity(); - this.switchContextToUser(user); + TrackedEntity te = get(TrackedEntity.class, "QS6w44flWAf"); JsonTrackedEntity json = GET("/tracker/trackedEntities/{id}?fields=trackedEntityType,orgUnit", te.getUid()) @@ -240,77 +295,75 @@ void getTrackedEntityByIdWithFields() { @Test void getTrackedEntityByIdWithAttributesReturnsTrackedEntityTypeAttributesOnly() { - TrackedEntity trackedEntity = trackedEntity(); - enroll(trackedEntity, program, orgUnit); - - TrackedEntityAttribute tea = - addTrackedEntityTypeAttributeValue(trackedEntity, ValueType.NUMBER, "12"); - addProgramAttributeValue(trackedEntity, program, ValueType.NUMBER, "24"); + TrackedEntity te = get(TrackedEntity.class, "dUE514NMOlo"); + // TETA + TrackedEntityAttribute tea1 = get(TrackedEntityAttribute.class, "numericAttr"); + TrackedEntityAttribute tea2 = get(TrackedEntityAttribute.class, "toUpdate000"); JsonList attributes = - GET( - "/tracker/trackedEntities/{id}?fields=attributes[attribute,value]", - trackedEntity.getUid()) + GET("/tracker/trackedEntities/{id}?fields=attributes[attribute,value]", te.getUid()) .content(HttpStatus.OK) .getList("attributes", JsonAttribute.class); - assertAll( - "include tracked entity type attributes only if no program query param is given", - () -> - assertEquals( - 1, - attributes.size(), - () -> String.format("expected 1 attribute instead got %s", attributes)), - () -> assertEquals(tea.getUid(), attributes.get(0).getAttribute()), - () -> assertEquals("12", attributes.get(0).getValue())); + assertContainsAll( + List.of(tea1.getUid(), tea2.getUid()), attributes, JsonAttribute::getAttribute); + assertContainsAll(List.of("rainy day", "70"), attributes, JsonAttribute::getValue); } @Test void getTrackedEntityByIdWithAttributesReturnsAllAttributes() { - TrackedEntity trackedEntity = trackedEntity(); - enroll(trackedEntity, program, orgUnit); - - TrackedEntityAttribute tea = - addTrackedEntityTypeAttributeValue(trackedEntity, ValueType.NUMBER, "12"); - TrackedEntityAttribute tea2 = - addProgramAttributeValue(trackedEntity, program, ValueType.NUMBER, "24"); + TrackedEntity te = get(TrackedEntity.class, "dUE514NMOlo"); + assertNotEmpty(te.getEnrollments(), "test expects a tracked entity with an enrollment"); + String program = te.getEnrollments().iterator().next().getProgram().getUid(); + // TETA + TrackedEntityAttribute tea1 = get(TrackedEntityAttribute.class, "numericAttr"); + TrackedEntityAttribute tea2 = get(TrackedEntityAttribute.class, "toUpdate000"); + // PTEA + TrackedEntityAttribute tea3 = get(TrackedEntityAttribute.class, "dIVt4l5vIOa"); JsonList attributes = GET( "/tracker/trackedEntities/{id}?program={id}&fields=attributes[attribute,value]", - trackedEntity.getUid(), - program.getUid()) + te.getUid(), + program) .content(HttpStatus.OK) .getList("attributes", JsonAttribute.class); assertContainsAll( - List.of(tea.getUid(), tea2.getUid()), attributes, JsonAttribute::getAttribute); - assertContainsAll(List.of("12", "24"), attributes, JsonAttribute::getValue); + List.of(tea1.getUid(), tea2.getUid(), tea3.getUid()), + attributes, + JsonAttribute::getAttribute); + assertContainsAll( + List.of("rainy day", "70", "Frank PTEA"), attributes, JsonAttribute::getValue); } @Test void getTrackedEntityByIdWithFieldsRelationships() { - TrackedEntity from = trackedEntity(); - TrackedEntity to = trackedEntity(); - Relationship r = relationship(from, to); - this.switchContextToUser(user); + TrackedEntity from = get(TrackedEntity.class, "mHWCacsGYYn"); + assertHasSize( + 1, from.getRelationshipItems(), "test expects a tracked entity with one relationship"); + RelationshipItem relItem = from.getRelationshipItems().iterator().next(); + Relationship r = get(Relationship.class, relItem.getRelationship().getUid()); + Event to = r.getTo().getEvent(); JsonList rels = GET("/tracker/trackedEntities/{id}?fields=relationships", from.getUid()) .content(HttpStatus.OK) .getList("relationships", JsonRelationship.class); - assertEquals(1, rels.size()); JsonRelationship relationship = assertFirstRelationship(r, rels); assertTrackedEntityWithinRelationship(from, relationship.getFrom()); assertTrackedEntityWithinRelationship(to, relationship.getTo()); } @Test - void getTrackedEntityByIdWithFieldsRelationshipsNoAccessToRelationshipType() { - TrackedEntity from = trackedEntity(); - TrackedEntity to = trackedEntity(); - relationship(relationshipTypeNotAccessible(), fromTrackedEntity(from), toTrackedEntity(to)); + void getTrackedEntityByIdWithFieldsRelationshipsNoAccessToRelationshipItemTo() { + TrackedEntity from = get(TrackedEntity.class, "mHWCacsGYYn"); + assertNotEmpty( + from.getRelationshipItems(), + "test expects a tracked entity with at least one relationship"); + + User user = userService.getUser("Z7870757a75"); this.switchContextToUser(user); JsonList relationships = @@ -321,18 +374,20 @@ void getTrackedEntityByIdWithFieldsRelationshipsNoAccessToRelationshipType() { assertEquals( 0, relationships.size(), - "user needs access to relationship type to access the relationship"); + "user needs access to from and to items to access the relationship"); } @Test - void getTrackedEntityByIdWithFieldsRelationshipsNoAccessToRelationshipItemTo() { - TrackedEntity from = trackedEntity(); - TrackedEntity to = trackedEntityNotInSearchScope(); - relationship(from, to); + void getTrackedEntityByIdWithFieldsRelationshipsNoAccessToRelationshipItemFrom() { + TrackedEntity to = get(TrackedEntity.class, "QesgJkTyTCk"); + assertNotEmpty( + to.getRelationshipItems(), "test expects a tracked entity with at least one relationship"); + + User user = userService.getUser("Z7870757a75"); this.switchContextToUser(user); JsonList relationships = - GET("/tracker/trackedEntities/{id}?fields=relationships", from.getUid()) + GET("/tracker/trackedEntities/{id}?fields=relationships", to.getUid()) .content(HttpStatus.OK) .getList("relationships", JsonRelationship.class); @@ -342,34 +397,6 @@ void getTrackedEntityByIdWithFieldsRelationshipsNoAccessToRelationshipItemTo() { "user needs access to from and to items to access the relationship"); } - @Test - void getTrackedEntityByIdWithFieldsRelationshipsNoAccessToBothRelationshipItems() { - TrackedEntity from = trackedEntityNotInSearchScope(); - TrackedEntity to = trackedEntityNotInSearchScope(); - relationship(from, to); - this.switchContextToUser(user); - - assertTrue( - GET("/tracker/trackedEntities/{id}?fields=relationships", from.getUid()) - .error(HttpStatus.FORBIDDEN) - .getMessage() - .contains("User has no access to TrackedEntity")); - } - - @Test - void getTrackedEntityByIdWithFieldsRelationshipsNoAccessToRelationshipItemFrom() { - TrackedEntity from = trackedEntityNotInSearchScope(); - TrackedEntity to = trackedEntity(); - relationship(from, to); - this.switchContextToUser(user); - - assertTrue( - GET("/tracker/trackedEntities/{id}?fields=relationships", from.getUid()) - .error(HttpStatus.FORBIDDEN) - .getMessage() - .contains("User has no access to TrackedEntity")); - } - @Test void getTrackedEntityByIdyWithFieldsRelationshipsNoAccessToTrackedEntityType() { TrackedEntityType type = trackedEntityTypeNotAccessible(); @@ -378,11 +405,8 @@ void getTrackedEntityByIdyWithFieldsRelationshipsNoAccessToTrackedEntityType() { relationship(from, to); this.switchContextToUser(user); - assertTrue( - GET("/tracker/trackedEntities/{id}?fields=relationships", from.getUid()) - .error(HttpStatus.FORBIDDEN) - .getMessage() - .contains("User has no access to TrackedEntity")); + GET("/tracker/trackedEntities/{id}?fields=relationships", from.getUid()) + .error(HttpStatus.FORBIDDEN); } @Test @@ -401,7 +425,7 @@ void shouldReturnNotFoundWhenGettingASoftDeletedTrackedEntityById() { @Test void getTrackedEntityReturnsCsvFormat() { - injectSecurityContextUser(user); + Program program = get(Program.class, "BFcipDERJnf"); HttpResponse response = GET( @@ -422,8 +446,15 @@ void getTrackedEntityReturnsCsvFormat() { @Test void getTrackedEntityCsvById() { - TrackedEntity te = trackedEntity(); - this.switchContextToUser(user); + TrackedEntity te = get(TrackedEntity.class, "QS6w44flWAf"); + List trackedEntityTypeAttributeValues = + te.getTrackedEntityAttributeValues().stream() + .filter(teav -> !"toDelete000".equals(teav.getAttribute().getUid())) + .toList(); + assertHasSize( + 2, + trackedEntityTypeAttributeValues, + "test expects the tracked entity to have 2 tracked entity type attribute values"); HttpResponse response = GET("/tracker/trackedEntities/{id}", te.getUid(), Accept(ContextUtils.CONTENT_TYPE_CSV)); @@ -432,27 +463,45 @@ void getTrackedEntityCsvById() { assertTrue(response.header("content-type").contains(ContextUtils.CONTENT_TYPE_CSV)); assertTrue(response.header("content-disposition").contains("filename=trackedEntity.csv")); - assertEquals(trackedEntityToCsv(te), csvResponse); - } - - String trackedEntityToCsv(TrackedEntity te) { - return """ - trackedEntity,trackedEntityType,createdAt,createdAtClient,updatedAt,updatedAtClient,orgUnit,inactive,deleted,potentialDuplicate,geometry,latitude,longitude,storedBy,createdBy,updatedBy,attrCreatedAt,attrUpdatedAt,attribute,displayName,value,valueType - """ - .concat( - String.join( - ",", - te.getUid(), - te.getTrackedEntityType().getUid(), - DateUtils.instantFromDate(te.getCreated()).toString(), - DateUtils.instantFromDate(te.getCreatedAtClient()).toString(), - DateUtils.instantFromDate(te.getLastUpdated()).toString(), - DateUtils.instantFromDate(te.getLastUpdatedAtClient()).toString(), - te.getOrganisationUnit().getUid(), - Boolean.toString(te.isInactive()), - Boolean.toString(te.isDeleted()), - Boolean.toString(te.isPotentialDuplicate()), - ",,,,,,,,,,," + "\n")); + assertStartsWith( + """ +trackedEntity,trackedEntityType,createdAt,createdAtClient,updatedAt,updatedAtClient,orgUnit,inactive,deleted,potentialDuplicate,geometry,latitude,longitude,storedBy,createdBy,updatedBy,attrCreatedAt,attrUpdatedAt,attribute,displayName,value,valueType +""", + csvResponse); + // TEAV order is not deterministic + assertContains(trackedEntityToCsv(te, trackedEntityTypeAttributeValues.get(0)), csvResponse); + assertContains(trackedEntityToCsv(te, trackedEntityTypeAttributeValues.get(1)), csvResponse); + } + + String trackedEntityToCsv(TrackedEntity te, TrackedEntityAttributeValue attributeValue) { + String value = attributeValue.getValue(); + if (attributeValue.getAttribute().getValueType() == ValueType.TEXT) { + value = "\"" + value + "\""; + } + return String.join( + ",", + te.getUid(), + te.getTrackedEntityType().getUid(), + DateUtils.instantFromDate(te.getCreated()).toString(), + DateUtils.instantFromDate(te.getCreatedAtClient()).toString(), + DateUtils.instantFromDate(te.getLastUpdated()).toString(), + DateUtils.instantFromDate(te.getLastUpdatedAtClient()).toString(), + te.getOrganisationUnit().getUid(), + Boolean.toString(te.isInactive()), + Boolean.toString(te.isDeleted()), + Boolean.toString(te.isPotentialDuplicate()), + ",,,", + importUser.getUsername(), + importUser.getUsername()) + + "," + + String.join( + ",", + DateUtils.instantFromDate(attributeValue.getCreated()).toString(), + DateUtils.instantFromDate(attributeValue.getLastUpdated()).toString(), + attributeValue.getAttribute().getUid(), + attributeValue.getAttribute().getDisplayName(), + value, + attributeValue.getAttribute().getValueType().name()); } @Test @@ -502,103 +551,97 @@ void getTrackedEntityReturnsCsvGZipFormat() { @Test void shouldGetEnrollmentWhenFieldsHasEnrollments() { - TrackedEntity trackedEntity = trackedEntity(); - Enrollment enrollment = enroll(trackedEntity, program, orgUnit); + TrackedEntity te = get(TrackedEntity.class, "dUE514NMOlo"); + assertHasSize(1, te.getEnrollments(), "test expects a tracked entity with one enrollment"); + Enrollment enrollment = te.getEnrollments().iterator().next(); JsonList json = - GET("/tracker/trackedEntities/{id}?fields=enrollments", trackedEntity.getUid()) + GET("/tracker/trackedEntities/{id}?fields=enrollments", te.getUid()) .content(HttpStatus.OK) .getList("enrollments", JsonEnrollment.class); - JsonEnrollment jsonEnrollment = assertDefaultEnrollmentResponse(json, enrollment); - - assertTrue(jsonEnrollment.getArray("relationships").isEmpty()); - assertTrue(jsonEnrollment.getAttributes().isEmpty()); - assertTrue(jsonEnrollment.getEvents().isEmpty()); + assertDefaultEnrollmentResponse(json, enrollment); } @Test void shouldGetNoEventRelationshipsWhenEventsHasNoRelationshipsAndFieldsIncludeAll() { - TrackedEntity trackedEntity = trackedEntity(); - - Enrollment enrollment = enroll(trackedEntity, program, orgUnit); - - Event event = eventWithDataValue(enrollment); - - enrollment.getEvents().add(event); - manager.update(enrollment); + TrackedEntity te = get(TrackedEntity.class, "mHWCacsGYYn"); + assertHasSize(1, te.getEnrollments(), "test expects a tracked entity with one enrollment"); + Enrollment enrollment = te.getEnrollments().iterator().next(); + assertHasSize(1, enrollment.getEvents(), "test expects an enrollment with one event"); + Event event = enrollment.getEvents().iterator().next(); + assertIsEmpty(event.getRelationshipItems(), "test expects an event with no relationships"); JsonList json = - GET("/tracker/trackedEntities/{id}?fields=enrollments", trackedEntity.getUid()) + GET("/tracker/trackedEntities/{id}?fields=enrollments", te.getUid()) .content(HttpStatus.OK) .getList("enrollments", JsonEnrollment.class); JsonEnrollment jsonEnrollment = assertDefaultEnrollmentResponse(json, enrollment); - assertTrue(jsonEnrollment.getArray("relationships").isEmpty()); - assertTrue(jsonEnrollment.getAttributes().isEmpty()); - JsonEvent jsonEvent = assertDefaultEventResponse(jsonEnrollment, event); - assertTrue(jsonEvent.getRelationships().isEmpty()); } @Test void shouldGetEventRelationshipsWhenEventHasRelationshipsAndFieldsIncludeEventRelationships() { - TrackedEntity trackedEntity = trackedEntity(); - - Enrollment enrollment = enroll(trackedEntity, program, orgUnit); - - Event event = eventWithDataValue(enrollment); - enrollment.getEvents().add(event); - manager.update(enrollment); - - Relationship teToEventRelationship = relationship(trackedEntity, event); + TrackedEntity te = get(TrackedEntity.class, "QS6w44flWAf"); + Enrollment enrollment = get(Enrollment.class, "nxP7UnKhomJ"); + assertHasSize(1, enrollment.getEvents(), "test expects an enrollment with one event"); + Event event = enrollment.getEvents().iterator().next(); + assertNotEmpty( + event.getRelationshipItems(), "test expects an event with at least one relationship"); + RelationshipItem relItem = te.getRelationshipItems().iterator().next(); + Relationship r = get(Relationship.class, relItem.getRelationship().getUid()); JsonList json = - GET("/tracker/trackedEntities/{id}?fields=enrollments", trackedEntity.getUid()) + GET("/tracker/trackedEntities/{id}?fields=enrollments", te.getUid()) .content(HttpStatus.OK) .getList("enrollments", JsonEnrollment.class); - JsonEnrollment jsonEnrollment = assertDefaultEnrollmentResponse(json, enrollment); - assertTrue(jsonEnrollment.getAttributes().isEmpty()); - assertTrue(jsonEnrollment.getArray("relationships").isEmpty()); + List enrollments = + json.stream().filter(en -> "nxP7UnKhomJ".equals(en.getEnrollment())).toList(); + assertNotEmpty( + enrollments, + () -> String.format("Expected enrollment \"nxP7UnKhomJ\" instead got %s", enrollments)); + JsonEnrollment jsonEnrollment = enrollments.get(0); + assertDefaultEnrollmentResponse(enrollment, jsonEnrollment); JsonEvent jsonEvent = assertDefaultEventResponse(jsonEnrollment, event); - - JsonRelationship relationship = jsonEvent.getRelationships().get(0); - - assertEquals(teToEventRelationship.getUid(), relationship.getRelationship()); - assertEquals( - trackedEntity.getUid(), relationship.getFrom().getTrackedEntity().getTrackedEntity()); - assertEquals(event.getUid(), relationship.getTo().getEvent().getEvent()); + assertTrue( + jsonEvent + .getRelationships() + .contains(JsonRelationship::getRelationship, actual -> r.getUid().equals(actual)), + () -> + String.format( + "Expected event to have relationship to TE instead got %s", + jsonEvent.getRelationships().toJson())); } @Test void shouldGetNoEventRelationshipsWhenEventHasRelationshipsAndFieldsExcludeEventRelationships() { - TrackedEntity trackedEntity = trackedEntity(); - - Enrollment enrollment = enroll(trackedEntity, program, orgUnit); - - Event event = eventWithDataValue(enrollment); - - enrollment.getEvents().add(event); - manager.update(enrollment); - - relationship(trackedEntity, event); + TrackedEntity te = get(TrackedEntity.class, "QS6w44flWAf"); + Enrollment enrollment = get(Enrollment.class, "nxP7UnKhomJ"); + assertHasSize(1, enrollment.getEvents(), "test expects an enrollment with one event"); + Event event = enrollment.getEvents().iterator().next(); + assertNotEmpty( + event.getRelationshipItems(), "test expects an event with at least one relationship"); JsonList json = GET( "/tracker/trackedEntities/{id}?fields=enrollments[*,events[!relationships]]", - trackedEntity.getUid()) + te.getUid()) .content(HttpStatus.OK) .getList("enrollments", JsonEnrollment.class); - JsonEnrollment jsonEnrollment = assertDefaultEnrollmentResponse(json, enrollment); - assertTrue(jsonEnrollment.getAttributes().isEmpty()); - assertTrue(jsonEnrollment.getArray("relationships").isEmpty()); + List enrollments = + json.stream().filter(en -> "nxP7UnKhomJ".equals(en.getEnrollment())).toList(); + assertNotEmpty( + enrollments, + () -> String.format("Expected enrollment \"nxP7UnKhomJ\" instead got %s", enrollments)); + JsonEnrollment jsonEnrollment = enrollments.get(0); + assertDefaultEnrollmentResponse(enrollment, jsonEnrollment); JsonEvent jsonEvent = assertDefaultEventResponse(jsonEnrollment, event); - assertHasNoMember(jsonEvent, "relationships"); } @@ -943,39 +986,24 @@ void getAttributeValuesImageByProgramAttribute() throws ConflictException { assertEquals("file content", response.content("image/png")); } - private Event eventWithDataValue(Enrollment enrollment) { - Event event = new Event(enrollment, programStage, enrollment.getOrganisationUnit(), coc); - event.setAutoFields(); - event.setOccurredDate(DateUtils.parseDate(EVENT_OCCURRED_AT)); - - dataElement = createDataElement('A'); - dataElement.setValueType(ValueType.TEXT); - manager.save(dataElement); - - EventDataValue eventDataValue = new EventDataValue(); - eventDataValue.setValue("value"); - eventDataValue.setDataElement(dataElement.getUid()); - eventDataValue.setCreatedByUserInfo(UserInfoSnapshot.from(user)); - eventDataValue.setLastUpdatedByUserInfo(UserInfoSnapshot.from(user)); - Set eventDataValues = Set.of(eventDataValue); - event.setEventDataValues(eventDataValues); - - manager.save(event); - return event; - } - private JsonEnrollment assertDefaultEnrollmentResponse( JsonList enrollments, Enrollment enrollment) { assertFalse(enrollments.isEmpty()); JsonEnrollment jsonEnrollment = enrollments.get(0); - assertHasMember(jsonEnrollment, "enrollment"); + assertDefaultEnrollmentResponse(enrollment, jsonEnrollment); + return jsonEnrollment; + } + + private static void assertDefaultEnrollmentResponse( + Enrollment enrollment, JsonEnrollment jsonEnrollment) { + assertHasMember(jsonEnrollment, "enrollment"); assertEquals(enrollment.getUid(), jsonEnrollment.getEnrollment()); assertEquals(enrollment.getTrackedEntity().getUid(), jsonEnrollment.getTrackedEntity()); - assertEquals(program.getUid(), jsonEnrollment.getProgram()); - assertEquals("ACTIVE", jsonEnrollment.getStatus()); - assertEquals(orgUnit.getUid(), jsonEnrollment.getOrgUnit()); + assertEquals(enrollment.getProgram().getUid(), jsonEnrollment.getProgram()); + assertEquals(enrollment.getStatus().name(), jsonEnrollment.getStatus()); + assertEquals(enrollment.getOrganisationUnit().getUid(), jsonEnrollment.getOrgUnit()); assertFalse(jsonEnrollment.getBoolean("deleted").booleanValue()); assertHasMember(jsonEnrollment, "enrolledAt"); assertHasMember(jsonEnrollment, "occurredAt"); @@ -984,8 +1012,6 @@ private JsonEnrollment assertDefaultEnrollmentResponse( assertHasMember(jsonEnrollment, "updatedAt"); assertHasMember(jsonEnrollment, "notes"); assertHasMember(jsonEnrollment, "followUp"); - - return jsonEnrollment; } private JsonEvent assertDefaultEventResponse(JsonEnrollment enrollment, Event event) { @@ -997,27 +1023,15 @@ private JsonEvent assertDefaultEventResponse(JsonEnrollment enrollment, Event ev assertEquals(event.getUid(), jsonEvent.getEvent()); assertEquals(event.getProgramStage().getUid(), jsonEvent.getProgramStage()); assertEquals(event.getEnrollment().getUid(), jsonEvent.getEnrollment()); - assertEquals(program.getUid(), jsonEvent.getProgram()); - assertEquals("ACTIVE", jsonEvent.getStatus()); - assertEquals(orgUnit.getUid(), jsonEvent.getOrgUnit()); + assertEquals(event.getProgramStage().getProgram().getUid(), jsonEvent.getProgram()); + assertEquals(event.getStatus().name(), jsonEvent.getStatus()); + assertEquals(event.getOrganisationUnit().getUid(), jsonEvent.getOrgUnit()); assertFalse(jsonEvent.getDeleted()); assertHasMember(jsonEvent, "createdAt"); assertHasMember(jsonEvent, "occurredAt"); - assertEquals(EVENT_OCCURRED_AT, jsonEvent.getString("occurredAt").string()); - assertHasMember(jsonEvent, "createdAtClient"); assertHasMember(jsonEvent, "updatedAt"); assertHasMember(jsonEvent, "notes"); assertHasMember(jsonEvent, "followUp"); - assertHasMember(jsonEvent, "followup"); - - JsonDataValue dataValue = jsonEvent.getDataValues().get(0); - - assertEquals(dataElement.getUid(), dataValue.getDataElement()); - assertEquals(event.getEventDataValues().iterator().next().getValue(), dataValue.getValue()); - assertHasMember(dataValue, "createdAt"); - assertHasMember(dataValue, "updatedAt"); - assertHasMember(dataValue, "createdBy"); - assertHasMember(dataValue, "updatedBy"); return jsonEvent; } @@ -1050,12 +1064,6 @@ private TrackedEntity trackedEntity() { return te; } - private TrackedEntity trackedEntityNotInSearchScope() { - TrackedEntity te = trackedEntity(anotherOrgUnit); - manager.save(te, false); - return te; - } - private TrackedEntity trackedEntity(TrackedEntityType trackedEntityType) { TrackedEntity te = trackedEntity(orgUnit, trackedEntityType); manager.save(te, false); @@ -1100,11 +1108,6 @@ private RelationshipType relationshipTypeAccessible( return type; } - private RelationshipType relationshipTypeNotAccessible() { - return relationshipType( - RelationshipEntity.TRACKED_ENTITY_INSTANCE, RelationshipEntity.TRACKED_ENTITY_INSTANCE); - } - private RelationshipType relationshipType(RelationshipEntity from, RelationshipEntity to) { RelationshipType type = createRelationshipType('A'); type.getFromConstraint().setRelationshipEntity(from); @@ -1122,21 +1125,6 @@ private Relationship relationship(TrackedEntity from, TrackedEntity to) { return relationship(type, fromTrackedEntity(from), toTrackedEntity(to)); } - private Relationship relationship(TrackedEntity from, Event to) { - RelationshipType type = - relationshipTypeAccessible( - RelationshipEntity.TRACKED_ENTITY_INSTANCE, RelationshipEntity.PROGRAM_STAGE_INSTANCE); - RelationshipItem fromItem = fromTrackedEntity(from); - RelationshipItem toItem = toEvent(to); - Relationship relationship = relationship(type, fromItem, toItem); - fromItem.setRelationship(relationship); - toItem.setRelationship(relationship); - to.getRelationshipItems().add(toItem); - manager.save(to, false); - manager.save(relationship, false); - return relationship; - } - private Relationship relationship( RelationshipType type, RelationshipItem fromItem, RelationshipItem toItem) { Relationship r = new Relationship(); @@ -1170,13 +1158,6 @@ private RelationshipItem toTrackedEntity(TrackedEntity to) { return toItem; } - private RelationshipItem toEvent(Event to) { - RelationshipItem toItem = new RelationshipItem(); - toItem.setEvent(to); - to.getRelationshipItems().add(toItem); - return toItem; - } - private void assertTrackedEntityWithinRelationship( TrackedEntity expected, JsonRelationshipItem json) { JsonRelationshipItem.JsonTrackedEntity jsonTe = json.getTrackedEntity(); @@ -1187,7 +1168,20 @@ private void assertTrackedEntityWithinRelationship( assertHasNoMember(json, "relationships"); // relationships are not // returned within // relationships - assertTrue(jsonTe.getArray("attributes").isEmpty()); + assertEquals( + expected.getTrackedEntityAttributeValues().isEmpty(), + jsonTe.getArray("attributes").isEmpty()); + } + + private void assertTrackedEntityWithinRelationship(Event expected, JsonRelationshipItem json) { + JsonRelationshipItem.JsonEvent jsonEvent = json.getEvent(); + assertFalse(jsonEvent.isEmpty(), "event should not be empty"); + assertEquals(expected.getUid(), jsonEvent.getEvent()); + assertHasNoMember(json, "trackedEntityType"); + assertHasNoMember(json, "orgUnit"); + assertHasNoMember(json, "relationships"); // relationships are not + // returned within + // relationships } private TrackedEntityAttribute addTrackedEntityTypeAttributeValue( @@ -1250,4 +1244,47 @@ private FileResource storeFile(String contentType, String content, char uniqueCh fr.setStorageStatus(FileResourceStorageStatus.STORED); return fr; } + + private T get(Class type, String uid) { + T t = manager.get(type, uid); + assertNotNull( + t, + () -> + String.format( + "'%s' with uid '%s' should have been created", type.getSimpleName(), uid)); + return t; + } + + public static void assertNoErrors(ImportReport report) { + assertNotNull(report); + assertEquals( + Status.OK, + report.getStatus(), + errorMessage( + "Expected import with status OK, instead got:%n", report.getValidationReport())); + } + + private static Supplier errorMessage(String errorTitle, ValidationReport report) { + return () -> { + StringBuilder msg = new StringBuilder(errorTitle); + report + .getErrors() + .forEach( + e -> { + msg.append(e.getErrorCode()); + msg.append(": "); + msg.append(e.getMessage()); + msg.append('\n'); + }); + return msg.toString(); + }; + } + + public static void assertNoErrors(ObjectBundleValidationReport report) { + assertNotNull(report); + List errors = new ArrayList<>(); + report.forEachErrorReport(err -> errors.add(err.toString())); + assertFalse( + report.hasErrorReports(), String.format("Expected no errors, instead got: %s%n", errors)); + } } diff --git a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/trackedentity/TrackedEntityFieldsParamMapperTest.java b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/trackedentity/TrackedEntityFieldsParamMapperTest.java index d1ede1162508..82f631b469c8 100644 --- a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/trackedentity/TrackedEntityFieldsParamMapperTest.java +++ b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/tracker/export/trackedentity/TrackedEntityFieldsParamMapperTest.java @@ -129,11 +129,12 @@ void mapWithExcludedSubFields() { map("enrollments[!uid,!relationships],relationships[relationship]"); assertTrue(params.isIncludeRelationships()); + assertFalse(params.isIncludeProgramOwners()); + assertTrue(params.isIncludeEnrollments()); assertTrue(params.getEnrollmentParams().isIncludeEvents()); assertTrue(params.getEnrollmentParams().isIncludeAttributes()); assertFalse(params.getEnrollmentParams().isIncludeRelationships()); - assertFalse(params.isIncludeProgramOwners()); } @Test @@ -141,9 +142,12 @@ void mapOnlyIncludeIfFieldIsRoot() { TrackedEntityParams params = map("enrollments[events,relationships]"); assertFalse(params.isIncludeRelationships()); + assertFalse(params.isIncludeProgramOwners()); + assertTrue(params.isIncludeEnrollments()); assertTrue(params.getTeEnrollmentParams().isIncludeEvents()); - assertFalse(params.isIncludeProgramOwners()); + assertTrue(params.getTeEnrollmentParams().isIncludeRelationships()); + assertFalse(params.getTeEnrollmentParams().isIncludeAttributes()); } @Test @@ -152,9 +156,10 @@ void mapOnlyIncludeIfNotAlsoExcluded() { TrackedEntityParams params = map("relationships,!relationships"); assertFalse(params.isIncludeRelationships()); + assertFalse(params.isIncludeProgramOwners()); + assertFalse(params.isIncludeEnrollments()); assertFalse(params.getTeEnrollmentParams().isIncludeEvents()); - assertFalse(params.isIncludeProgramOwners()); params = map("!relationships,relationships"); @@ -169,9 +174,12 @@ void mapRootInclusionPrecedesSubfieldExclusion() { TrackedEntityParams params = map("enrollments,enrollments[!status]"); assertFalse(params.isIncludeRelationships()); + assertFalse(params.isIncludeProgramOwners()); + assertTrue(params.isIncludeEnrollments()); assertTrue(params.getTeEnrollmentParams().isIncludeEvents()); - assertFalse(params.isIncludeProgramOwners()); + assertTrue(params.getTeEnrollmentParams().isIncludeAttributes()); + assertTrue(params.getTeEnrollmentParams().isIncludeRelationships()); } static Stream mapEnrollmentsAndEvents() { diff --git a/dhis-2/dhis-test-web-api/src/test/resources/tracker/event_and_enrollment.json b/dhis-2/dhis-test-web-api/src/test/resources/tracker/event_and_enrollment.json index 12a75f2722c6..c43114728de9 100644 --- a/dhis-2/dhis-test-web-api/src/test/resources/tracker/event_and_enrollment.json +++ b/dhis-2/dhis-test-web-api/src/test/resources/tracker/event_and_enrollment.json @@ -42,10 +42,8 @@ "identifier": "h4w96yEMlzO" }, "inactive": true, - "deleted": false, - "potentialDuplicate": false, "createdAtClient": "2018-10-01T12:17:30.163", - "relationships": [], + "updatedAtClient": "2018-10-07T12:17:30.163", "attributes": [ { "valueType": "TEXT", @@ -71,8 +69,7 @@ }, "value": "88" } - ], - "enrollments": [] + ] }, { "trackedEntity": "dUE514NMOlo", @@ -84,11 +81,7 @@ "idScheme": "UID", "identifier": "h4w96yEMlzO" }, - "inactive": false, - "deleted": false, - "potentialDuplicate": false, "createdAtClient": "2018-11-01T12:17:30.163", - "relationships": [], "attributes": [ { "valueType": "TEXT", @@ -122,8 +115,7 @@ }, "value": "70" } - ], - "enrollments": [] + ] }, { "trackedEntity": "mHWCacsGYYn", @@ -135,10 +127,6 @@ "idScheme": "UID", "identifier": "h4w96yEMlzO" }, - "inactive": false, - "deleted": false, - "potentialDuplicate": false, - "relationships": [], "attributes": [ { "valueType": "TEXT", @@ -164,8 +152,7 @@ }, "value": "72" } - ], - "enrollments": [] + ] }, { "trackedEntity": "QesgJkTyTCk", @@ -177,10 +164,6 @@ "idScheme": "UID", "identifier": "h4w96yEMlzO" }, - "inactive": false, - "deleted": false, - "potentialDuplicate": false, - "relationships": [], "attributes": [ { "valueType": "TEXT", @@ -206,8 +189,7 @@ }, "value": "89" } - ], - "enrollments": [] + ] }, { "trackedEntity": "guVNoAerxWo", @@ -219,10 +201,6 @@ "idScheme": "UID", "identifier": "tSsGrtfRzjY" }, - "inactive": false, - "deleted": false, - "potentialDuplicate": false, - "relationships": [], "attributes": [ { "valueType": "TEXT", @@ -248,8 +226,7 @@ }, "value": "91" } - ], - "enrollments": [] + ] }, { "trackedEntity": "woitxQbWYNq", @@ -261,10 +238,6 @@ "idScheme": "UID", "identifier": "RojfDTBhoGC" }, - "inactive": false, - "deleted": false, - "potentialDuplicate": false, - "relationships": [], "attributes": [ { "valueType": "TEXT", @@ -290,8 +263,7 @@ }, "value": "90" } - ], - "enrollments": [] + ] }, { "trackedEntity": "XUitxQbWYNq", @@ -302,12 +274,18 @@ "orgUnit": { "idScheme": "UID", "identifier": "DiszpKrYNg8" + } + }, + { + "trackedEntity": "H8732208127", + "trackedEntityType": { + "idScheme": "UID", + "identifier": "ja8NY4PW7Xm" }, - "inactive": false, - "deleted": false, - "potentialDuplicate": false, - "relationships": [], - "enrollments": [] + "orgUnit": { + "idScheme": "UID", + "identifier": "DiszpKrYNg8" + } } ], "enrollments": [ @@ -324,16 +302,9 @@ "idScheme": "UID", "identifier": "h4w96yEMlzO" }, - "orgUnitName": "Mbokie CHP", "enrolledAt": "2021-02-28T12:05:00.000", "occurredAt": "2021-02-28T12:05:00.000", - "scheduledAt": "2021-02-28T12:05:00.000", - "followUp": false, - "deleted": false, - "events": [], - "relationships": [], - "attributes": [], - "notes": [] + "scheduledAt": "2021-02-28T12:05:00.000" }, { "enrollment": "nxP8UnKhomJ", @@ -350,13 +321,7 @@ }, "enrolledAt": "2021-04-28T12:05:00.000", "occurredAt": "2021-04-28T12:05:00.000", - "scheduledAt": "2021-04-28T12:05:00.000", - "followUp": false, - "deleted": false, - "events": [], - "relationships": [], - "attributes": [], - "notes": [] + "scheduledAt": "2021-04-28T12:05:00.000" }, { "enrollment": "TvctPPhpD8z", @@ -371,16 +336,24 @@ "idScheme": "UID", "identifier": "h4w96yEMlzO" }, - "orgUnitName": "Mbokie CHP", "enrolledAt": "2021-03-28T12:05:00.000", "occurredAt": "2021-03-28T12:05:00.000", "scheduledAt": "2021-02-28T12:05:00.000", - "followUp": false, - "deleted": false, - "events": [], - "relationships": [], - "attributes": [], - "notes": [] + "attributes": [ + { + "attribute": { + "idScheme": "UID", + "identifier": "dIVt4l5vIOa" + }, + "value": "Frank PTEA" + } + ], + "notes": [ + { + "note": "f9423652692", + "value": "enrollment comment value" + } + ] }, { "enrollment": "JuioKiICQqI", @@ -395,16 +368,9 @@ "idScheme": "UID", "identifier": "uoNW0E3xXUy" }, - "orgUnitName": "test-orgunit-2", "enrolledAt": "2021-02-28T12:05:00.000", "occurredAt": "2021-02-28T12:05:00.000", - "scheduledAt": "2021-02-28T12:05:00.000", - "followUp": false, - "deleted": false, - "events": [], - "relationships": [], - "attributes": [], - "notes": [] + "scheduledAt": "2021-02-28T12:05:00.000" }, { "enrollment": "iHFHfPKTSYP", @@ -419,16 +385,9 @@ "idScheme": "UID", "identifier": "uoNW0E3xXUy" }, - "orgUnitName": "test-orgunit-2", "enrolledAt": "2021-02-28T12:05:00.000", "occurredAt": "2021-02-28T12:05:00.000", - "scheduledAt": "2021-02-28T12:05:00.000", - "followUp": false, - "deleted": false, - "events": [], - "relationships": [], - "attributes": [], - "notes": [] + "scheduledAt": "2021-02-28T12:05:00.000" }, { "enrollment": "ipBifypAQTo", @@ -443,16 +402,9 @@ "idScheme": "UID", "identifier": "tSsGrtfRzjY" }, - "orgUnitName": "test-orgunit-3", "enrolledAt": "2021-02-28T12:05:00.000", "occurredAt": "2021-02-28T12:05:00.000", - "scheduledAt": "2021-02-28T12:05:00.000", - "followUp": false, - "deleted": false, - "events": [], - "relationships": [], - "attributes": [], - "notes": [] + "scheduledAt": "2021-02-28T12:05:00.000" }, { "enrollment": "qxOSXoEZkOA", @@ -467,16 +419,9 @@ "idScheme": "UID", "identifier": "RojfDTBhoGC" }, - "orgUnitName": "test-orgunit-3", "enrolledAt": "2021-02-28T12:05:00.000", "occurredAt": "2021-02-28T12:05:00.000", - "scheduledAt": "2021-02-28T12:05:00.000", - "followUp": false, - "deleted": false, - "events": [], - "relationships": [], - "attributes": [], - "notes": [] + "scheduledAt": "2021-02-28T12:05:00.000" }, { "enrollment": "HDWTYSYkICe", @@ -491,16 +436,9 @@ "idScheme": "UID", "identifier": "DiszpKrYNg8" }, - "orgUnitName": "test-orgunit-3", "enrolledAt": "2021-02-28T12:05:00.000", "occurredAt": "2021-02-28T12:05:00.000", - "scheduledAt": "2021-02-28T12:05:00.000", - "followUp": false, - "deleted": false, - "events": [], - "relationships": [], - "attributes": [], - "notes": [] + "scheduledAt": "2021-02-28T12:05:00.000" }, { "enrollment": "FXWSSZunTLk", @@ -515,16 +453,9 @@ "idScheme": "UID", "identifier": "lbDXJBlvtZe" }, - "orgUnitName": "test-orgunit-3", "enrolledAt": "2021-02-28T12:05:00.000", "occurredAt": "2021-02-28T12:05:00.000", - "scheduledAt": "2021-02-28T12:05:00.000", - "followUp": false, - "deleted": false, - "events": [], - "relationships": [], - "attributes": [], - "notes": [] + "scheduledAt": "2021-02-28T12:05:00.000" }, { "enrollment": "GYWSSZunTLk", @@ -539,16 +470,9 @@ "idScheme": "UID", "identifier": "DiszpKrYNg8" }, - "orgUnitName": "test-orgunit-3", "enrolledAt": "2021-02-28T12:05:00.000", "occurredAt": "2021-02-28T12:05:00.000", - "scheduledAt": "2021-02-28T12:05:00.000", - "followUp": false, - "deleted": false, - "events": [], - "relationships": [], - "attributes": [], - "notes": [] + "scheduledAt": "2021-02-28T12:05:00.000" } ], "events": [ @@ -568,14 +492,12 @@ "idScheme": "UID", "identifier": "h4w96yEMlzO" }, - "relationships": [], "occurredAt": "2019-01-25T12:10:38.100", "scheduledAt": "2019-01-28T12:32:38.100", "createdAtClient": "2020-01-25T12:10:38.100", "updatedAtClient": "2020-01-26T12:10:38.100", "storedBy": "admin", "followUp": true, - "deleted": false, "attributeOptionCombo": { "idScheme": "UID", "identifier": "HllvX50cXC0" @@ -595,8 +517,7 @@ }, "value": "value00001", "createdAt": "2021-07-01T12:05:00", - "storedBy": null, - "providedElsewhere": false + "storedBy": null }, { "dataElement": { @@ -605,8 +526,7 @@ }, "value": "option1", "createdAt": "2021-07-01T12:05:00", - "storedBy": null, - "providedElsewhere": false + "storedBy": null }, { "dataElement": { @@ -615,8 +535,7 @@ }, "value": "88", "createdAt": "2021-07-01T12:05:00", - "storedBy": null, - "providedElsewhere": false + "storedBy": null } ], "notes": [ @@ -648,14 +567,12 @@ "idScheme": "UID", "identifier": "h4w96yEMlzO" }, - "relationships": [], "occurredAt": "2020-01-28T00:00:00.000", "scheduledAt": "2019-01-28T12:10:38.100", "createdAtClient": "2019-01-25T12:10:38.100", "updatedAtClient": "2021-01-26T12:10:38.100", "storedBy": "admin", "followUp": true, - "deleted": false, "attributeOptionCombo": { "idScheme": "UID", "identifier": "HllvX50cXC0" @@ -674,8 +591,7 @@ }, "value": "value00002", "created": "2021-07-01T12:05:00", - "storedBy": null, - "providedElsewhere": false + "storedBy": null }, { "dataElement": { @@ -684,8 +600,7 @@ }, "value": "value00002", "created": "2021-07-01T12:05:00", - "storedBy": null, - "providedElsewhere": false + "storedBy": null }, { "dataElement": { @@ -694,8 +609,7 @@ }, "value": "option2", "created": "2021-07-01T12:05:00", - "storedBy": null, - "providedElsewhere": false + "storedBy": null }, { "dataElement": { @@ -704,8 +618,7 @@ }, "value": "70", "created": "2021-07-01T12:05:00", - "storedBy": null, - "providedElsewhere": false + "storedBy": null }, { "dataElement": { @@ -714,11 +627,9 @@ }, "value": "70", "created": "2021-07-01T12:05:00", - "storedBy": null, - "providedElsewhere": false + "storedBy": null } ], - "notes": [], "assignedUser": { "uid": "xE7jOejl9FI", "firstName": "John", @@ -747,7 +658,6 @@ "scheduledAt": "2019-01-28T12:10:38.100", "storedBy": "admin", "followUp": true, - "deleted": false, "attributeOptionCombo": { "idScheme": "UID", "identifier": "HllvX50cXC0" @@ -757,8 +667,7 @@ "idScheme": "UID", "identifier": "xYerKDKCefk" } - ], - "notes": [] + ] }, { "event": "JaRDIvcEcEx", @@ -781,7 +690,6 @@ "scheduledAt": "2019-01-28T12:10:38.100", "storedBy": "admin", "followUp": true, - "deleted": false, "attributeOptionCombo": { "idScheme": "UID", "identifier": "HllvX50cXC0" @@ -791,8 +699,7 @@ "idScheme": "UID", "identifier": "xYerKDKCefk" } - ], - "notes": [] + ] }, { "event": "QRYjLTiJTrA", @@ -827,7 +734,6 @@ "identifier": "DiszpKrYNg8" }, "status": "ACTIVE", - "deleted": false, "dataValues": [ { "created": "2022-04-22T06:00:38.339", @@ -835,20 +741,16 @@ "idScheme": "UID", "identifier": "GieVkTxp4HH" }, - "value": "15", - "providedElsewhere": false + "value": "15" }, { "dataElement": { "idScheme": "UID", "identifier": "GieVkTxp4HG" }, - "value": "1.5", - "providedElsewhere": false + "value": "1.5" } - ], - "notes": [], - "relationships": [] + ] }, { "event": "kWjSezkXHVp", @@ -883,7 +785,6 @@ "identifier": "DiszpKrYNg8" }, "status": "ACTIVE", - "deleted": false, "dataValues": [ { "created": "2022-04-22T06:00:34.319", @@ -891,12 +792,9 @@ "idScheme": "UID", "identifier": "GieVkTxp4HH" }, - "value": "14", - "providedElsewhere": false + "value": "14" } - ], - "notes": [], - "relationships": [] + ] }, { "event": "OTmjvJDn0Fu", @@ -931,7 +829,6 @@ "identifier": "DiszpKrYNg8" }, "status": "ACTIVE", - "deleted": false, "dataValues": [ { "created": "2022-04-22T06:00:30.559", @@ -939,12 +836,9 @@ "idScheme": "UID", "identifier": "GieVkTxp4HH" }, - "value": "13", - "providedElsewhere": false + "value": "13" } - ], - "notes": [], - "relationships": [] + ] }, { "event": "ck7DzdxqLqA", @@ -979,7 +873,6 @@ "identifier": "DiszpKrYNg8" }, "status": "ACTIVE", - "deleted": false, "dataValues": [ { "created": "2022-04-22T06:00:14.224", @@ -987,12 +880,9 @@ "idScheme": "UID", "identifier": "GieVkTxp4HH" }, - "value": "12", - "providedElsewhere": false + "value": "12" } - ], - "notes": [], - "relationships": [] + ] }, { "event": "lumVtWwwy0O", @@ -1078,17 +968,14 @@ "idScheme": "UID", "identifier": "tSsGrtfRzjY" }, - "relationships": [], "occurredAt": "2019-01-28T00:00:00.000", "scheduledAt": "2019-01-28T12:10:38.100", "storedBy": "admin", "followUp": true, - "deleted": false, "attributeOptionCombo": { "idScheme": "UID", "identifier": "HllvX50cXC0" - }, - "notes": [] + } }, { "event": "SbUJzkxKYAG", @@ -1106,12 +993,10 @@ "idScheme": "UID", "identifier": "RojfDTBhoGC" }, - "relationships": [], "occurredAt": "2019-01-28T00:00:00.000", "scheduledAt": "2019-01-28T11:10:38.100", "storedBy": "tracker", "followUp": true, - "deleted": false, "attributeOptionCombo": { "idScheme": "UID", "identifier": "HllvX50cXC0" @@ -1133,17 +1018,14 @@ "idScheme": "UID", "identifier": "DiszpKrYNg8" }, - "relationships": [], "occurredAt": "2019-01-28T00:00:00.000", "scheduledAt": "2019-01-28T12:10:38.100", "storedBy": "admin", "followUp": true, - "deleted": false, "attributeOptionCombo": { "idScheme": "UID", "identifier": "HllvX50cXC0" - }, - "notes": [] + } }, { "event": "YKmfzHdjUDL", @@ -1161,17 +1043,14 @@ "idScheme": "UID", "identifier": "lbDXJBlvtZe" }, - "relationships": [], "occurredAt": "2019-01-28T00:00:00.000", "scheduledAt": "2019-01-28T12:10:38.100", "storedBy": "admin", "followUp": true, - "deleted": false, "attributeOptionCombo": { "idScheme": "UID", "identifier": "HllvX50cXC0" - }, - "notes": [] + } }, { "event": "G9PbzJY8bJG", @@ -1188,17 +1067,14 @@ "idScheme": "UID", "identifier": "g4w96yEMlzO" }, - "relationships": [], "occurredAt": "2019-01-28T00:00:00.000", "scheduledAt": "2019-01-28T12:10:38.100", "storedBy": "admin", "followUp": true, - "deleted": false, "attributeOptionCombo": { "idScheme": "UID", "identifier": "HllvX50cXC0" - }, - "notes": [] + } }, { "event": "H0PbzJY8bJG", @@ -1216,17 +1092,14 @@ "idScheme": "UID", "identifier": "DiszpKrYNg8" }, - "relationships": [], "occurredAt": "2019-01-28T00:00:00.000", "scheduledAt": "2019-01-28T12:10:38.100", "storedBy": "admin", "followUp": true, - "deleted": false, "attributeOptionCombo": { "idScheme": "UID", "identifier": "HllvX50cXC0" - }, - "notes": [] + } } ], "relationships": [ @@ -1237,8 +1110,6 @@ "identifier": "TV9oB9LT3sh" }, "createdAtClient": "2018-10-01T12:17:30.163", - "bidirectional": false, - "deleted": false, "from": { "trackedEntity": "QS6w44flWAf" }, @@ -1253,14 +1124,51 @@ "identifier": "TV9oB9LT3sh" }, "createdAtClient": "2018-11-01T13:24:37.118", - "bidirectional": false, - "deleted": false, "from": { "trackedEntity": "dUE514NMOlo" }, "to": { "event": "pTzf9KYMk72" } + }, + { + "relationship": "x8919212736", + "relationshipType": { + "idScheme": "UID", + "identifier": "TV9oB9LT3sh" + }, + "from": { + "trackedEntity": "mHWCacsGYYn" + }, + "to": { + "event": "QRYjLTiJTrA" + } + }, + { + "relationship": "N8800829a58", + "relationshipType": { + "idScheme": "UID", + "identifier": "m1575931405" + }, + "from": { + "trackedEntity": "H8732208127" + }, + "to": { + "trackedEntity": "QesgJkTyTCk" + } + }, + { + "relationship": "p53a6314631", + "relationshipType": { + "idScheme": "UID", + "identifier": "xLmPUYJX8Ks" + }, + "from": { + "trackedEntity": "dUE514NMOlo" + }, + "to": { + "enrollment": "nxP7UnKhomJ" + } } ], "username": "system-process" diff --git a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/category/CategoryOptionComboController.java b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/category/CategoryOptionComboController.java index e243d3a836a0..a20d43baaf3e 100644 --- a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/category/CategoryOptionComboController.java +++ b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/controller/category/CategoryOptionComboController.java @@ -27,18 +27,64 @@ */ package org.hisp.dhis.webapi.controller.category; +import static org.hisp.dhis.security.Authorities.F_CATEGORY_OPTION_COMBO_MERGE; +import static org.hisp.dhis.webapi.controller.CrudControllerAdvice.getHelpfulMessage; +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; + +import jakarta.persistence.PersistenceException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.hisp.dhis.category.CategoryOptionCombo; +import org.hisp.dhis.common.Maturity.Beta; import org.hisp.dhis.common.OpenApi; +import org.hisp.dhis.dxf2.webmessage.WebMessage; +import org.hisp.dhis.dxf2.webmessage.WebMessageUtils; +import org.hisp.dhis.feedback.ConflictException; +import org.hisp.dhis.feedback.MergeReport; +import org.hisp.dhis.merge.MergeParams; +import org.hisp.dhis.merge.MergeService; import org.hisp.dhis.query.GetObjectListParams; +import org.hisp.dhis.security.RequiresAuthority; import org.hisp.dhis.webapi.controller.AbstractCrudController; +import org.springframework.http.HttpStatus; import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.ResponseStatus; /** * @author Morten Olav Hansen */ +@Slf4j @Controller +@RequiredArgsConstructor @RequestMapping("/api/categoryOptionCombos") @OpenApi.Document(classifiers = {"team:platform", "purpose:metadata"}) public class CategoryOptionComboController - extends AbstractCrudController {} + extends AbstractCrudController { + + private final MergeService categoryOptionComboMergeService; + + @Beta + @ResponseStatus(HttpStatus.OK) + @RequiresAuthority(anyOf = F_CATEGORY_OPTION_COMBO_MERGE) + @PostMapping(value = "/merge", produces = APPLICATION_JSON_VALUE) + public @ResponseBody WebMessage mergeCategoryOptionCombos(@RequestBody MergeParams params) + throws ConflictException { + log.info("CategoryOptionCombo merge received"); + + MergeReport report; + try { + report = categoryOptionComboMergeService.processMerge(params); + } catch (PersistenceException ex) { + String helpfulMessage = getHelpfulMessage(ex); + log.error("Error while processing CategoryOptionCombo merge: {}", helpfulMessage); + throw ex; + } + + log.info("CategoryOptionCombo merge processed with report: {}", report); + return WebMessageUtils.mergeReport(report); + } +} diff --git a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/security/session/NonRedisSessionConfig.java b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/security/session/NonRedisSessionConfig.java index da0883deefaa..285f00473516 100644 --- a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/security/session/NonRedisSessionConfig.java +++ b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/security/session/NonRedisSessionConfig.java @@ -28,13 +28,19 @@ package org.hisp.dhis.webapi.security.session; import jakarta.servlet.Filter; +import jakarta.servlet.http.HttpSession; import org.hisp.dhis.condition.RedisDisabledCondition; +import org.hisp.dhis.external.conf.ConfigurationKey; +import org.hisp.dhis.external.conf.DhisConfigurationProvider; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Conditional; import org.springframework.context.annotation.Configuration; +import org.springframework.context.event.EventListener; import org.springframework.core.annotation.Order; import org.springframework.security.core.session.SessionRegistry; import org.springframework.security.core.session.SessionRegistryImpl; +import org.springframework.security.web.session.HttpSessionCreatedEvent; import org.springframework.security.web.session.HttpSessionEventPublisher; import org.springframework.web.filter.CharacterEncodingFilter; @@ -53,6 +59,8 @@ @Conditional(RedisDisabledCondition.class) public class NonRedisSessionConfig { + @Autowired private DhisConfigurationProvider config; + @Bean public SessionRegistry sessionRegistry() { return new SessionRegistryImpl(); @@ -67,4 +75,11 @@ public HttpSessionEventPublisher httpSessionEventPublisher() { public Filter springSessionRepositoryFilter() { return new CharacterEncodingFilter(); } + + @EventListener + public void sessionCreated(HttpSessionCreatedEvent event) { + HttpSession session = event.getSession(); + int sessionTimeout = config.getIntProperty(ConfigurationKey.SYSTEM_SESSION_TIMEOUT); + session.setMaxInactiveInterval(sessionTimeout); + } } diff --git a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/security/session/RedisSpringSessionConfig.java b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/security/session/RedisSpringSessionConfig.java index 8d32db09ae53..8f5def3919ac 100644 --- a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/security/session/RedisSpringSessionConfig.java +++ b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/security/session/RedisSpringSessionConfig.java @@ -28,6 +28,8 @@ package org.hisp.dhis.webapi.security.session; import org.hisp.dhis.condition.RedisEnabledCondition; +import org.hisp.dhis.external.conf.ConfigurationKey; +import org.hisp.dhis.external.conf.DhisConfigurationProvider; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Conditional; @@ -56,6 +58,7 @@ @Conditional(RedisEnabledCondition.class) @EnableRedisHttpSession public class RedisSpringSessionConfig { + @Autowired private DhisConfigurationProvider config; @Bean public RedisIndexedSessionRepository sessionRepository( @@ -69,6 +72,9 @@ public RedisIndexedSessionRepository sessionRepository( redisTemplate.afterPropertiesSet(); RedisIndexedSessionRepository repository = new RedisIndexedSessionRepository(redisTemplate); repository.setDefaultSerializer(new JdkSerializationRedisSerializer()); + + int sessionTimeout = config.getIntProperty(ConfigurationKey.SYSTEM_SESSION_TIMEOUT); + repository.setDefaultMaxInactiveInterval(sessionTimeout); return repository; } diff --git a/dhis-2/pom.xml b/dhis-2/pom.xml index b92e7c4c3e30..c5718fd77333 100644 --- a/dhis-2/pom.xml +++ b/dhis-2/pom.xml @@ -85,7 +85,7 @@ - 3.2.2 + 3.3.0 0.6.1 @@ -123,7 +123,7 @@ 0.10.1 6.2.1 - 42.7.4 + 42.7.5 2.5.1 1.10.1 9.1.0 @@ -157,7 +157,7 @@ 1.0.23 2.1 2.1 - 5.3.0 + 5.4.0 2.1.7 @@ -169,7 +169,7 @@ 2.38.0 - 4.1.116.Final + 4.1.117.Final 4.8.179 @@ -238,9 +238,9 @@ 3.5.0 2.6.0 2.18.0 - 11.1.1 + 12.0.0 4.8.6.6 - 2.44.1 + 2.44.2 0.8.12 1.2.0 1.0.36