Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2004-2022, University of Oslo
* Copyright (c) 2004-2026, University of Oslo
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
Expand Down Expand Up @@ -308,6 +308,8 @@ void shouldImportWithWarningWhenDataElementOfTypeOptionWithValidValueIsAssignedB
assertHasOnlyWarnings(importReport, E1308);
if (!hasValidValue) {
assertHasOnlyErrors(importReport, E1125);
} else {
assertNoErrors(importReport);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2004-2022, University of Oslo
* Copyright (c) 2004-2026, University of Oslo
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
Expand Down Expand Up @@ -34,25 +34,37 @@
import com.google.common.collect.Lists;
import java.util.Date;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import javax.annotation.Nonnull;
import lombok.RequiredArgsConstructor;
import org.hisp.dhis.common.UID;
import org.hisp.dhis.dataelement.DataElement;
import org.hisp.dhis.feedback.ForbiddenException;
import org.hisp.dhis.feedback.NotFoundException;
import org.hisp.dhis.program.UserInfoSnapshot;
import org.hisp.dhis.trackedentity.TrackedEntityAttribute;
import org.hisp.dhis.tracker.imports.ParamsConverter;
import org.hisp.dhis.tracker.imports.TrackerImportParams;
import org.hisp.dhis.tracker.imports.bundle.persister.CommitService;
import org.hisp.dhis.tracker.imports.bundle.persister.PersistenceException;
import org.hisp.dhis.tracker.imports.bundle.persister.TrackerObjectDeletionService;
import org.hisp.dhis.tracker.imports.bundle.persister.TrackerPersister.PersistResult;
import org.hisp.dhis.tracker.imports.domain.Attribute;
import org.hisp.dhis.tracker.imports.domain.DataValue;
import org.hisp.dhis.tracker.imports.domain.Enrollment;
import org.hisp.dhis.tracker.imports.domain.TrackerDto;
import org.hisp.dhis.tracker.imports.domain.TrackerEvent;
import org.hisp.dhis.tracker.imports.domain.TrackerObjects;
import org.hisp.dhis.tracker.imports.notification.EntityNotifications;
import org.hisp.dhis.tracker.imports.preheat.TrackerPreheat;
import org.hisp.dhis.tracker.imports.preheat.TrackerPreheatService;
import org.hisp.dhis.tracker.imports.preheat.supplier.OptionValueSupplier;
import org.hisp.dhis.tracker.imports.programrule.ProgramRuleService;
import org.hisp.dhis.tracker.imports.programrule.executor.enrollment.AssignAttributeExecutor;
import org.hisp.dhis.tracker.imports.programrule.executor.event.AssignDataValueExecutor;
import org.hisp.dhis.tracker.imports.report.PersistenceReport;
import org.hisp.dhis.tracker.imports.report.TrackerTypeReport;
import org.hisp.dhis.user.UserDetails;
Expand All @@ -77,6 +89,8 @@ public class DefaultTrackerBundleService implements TrackerBundleService {

private final TrackerObjectDeletionService deletionService;

private final OptionValueSupplier optionValueSupplier;

private final ObjectMapper mapper;

@Nonnull
Expand All @@ -97,9 +111,96 @@ public TrackerBundle create(
public TrackerBundle runRuleEngine(@Nonnull TrackerBundle trackerBundle) {
programRuleService.calculateRuleEffects(trackerBundle, trackerBundle.getPreheat());

optionValueSupplier.preheatAdd(
collectRuleAssignedValues(trackerBundle), trackerBundle.getPreheat());

return trackerBundle;
}

/**
* Collects the values {@code ASSIGN} rule actions are going to apply, shaped as a synthetic
* {@link TrackerObjects} payload the {@link OptionValueSupplier} can resolve option codes from.
*
* <p>Rule engine validation rejects unknown option codes based on {@link
* TrackerPreheat#isValidOptionCode(Long, String)}, but {@code ASSIGN} actions can add or
* overwrite data values and attributes that were not in the original payload, so the codes they
* introduce were never resolved during preheat and valid data would be rejected with E1125.
*
* <p>The values are already final here: they are the rule engine's evaluated output, captured in
* the executors {@code calculateRuleEffects} just built, so nothing evaluated later can change
* them.
*
* <p>All assigned values are gathered onto a single synthetic event and enrollment. The supplier
* only looks at (data element, value) and (attribute, value) pairs, so which or how many real
* entities the values belong to does not matter.
*/
private TrackerObjects collectRuleAssignedValues(TrackerBundle bundle) {
TrackerPreheat preheat = bundle.getPreheat();

Set<DataValue> assignedDataValues =
bundle.getEventRuleActionExecutors().values().stream()
.flatMap(List::stream)
.filter(AssignDataValueExecutor.class::isInstance)
.map(AssignDataValueExecutor.class::cast)
.map(executor -> toDataValue(preheat, executor))
.filter(Objects::nonNull)
.collect(Collectors.toSet());

List<Attribute> assignedAttributes =
bundle.getEnrollmentRuleActionExecutors().values().stream()
.flatMap(List::stream)
.filter(AssignAttributeExecutor.class::isInstance)
.map(AssignAttributeExecutor.class::cast)
.map(executor -> toAttribute(preheat, executor))
.filter(Objects::nonNull)
.toList();

return TrackerObjects.builder()
.events(
List.of(
TrackerEvent.builder()
.event(UID.generate())
.dataValues(assignedDataValues)
.build()))
.enrollments(
List.of(
Enrollment.builder()
.enrollment(UID.generate())
.attributes(assignedAttributes)
.build()))
.build();
}

/**
* Executors only know the target's UID, while the supplier looks metadata up by the payload's id
* scheme, so the identifier has to be converted the same way the executors themselves do when
* they apply the value.
*/
private DataValue toDataValue(TrackerPreheat preheat, AssignDataValueExecutor executor) {
DataElement dataElement = preheat.getDataElement(executor.getDataElementUid().getValue());
if (dataElement == null) {
return null;
}

return DataValue.builder()
.dataElement(preheat.getIdSchemes().toMetadataIdentifier(dataElement))
.value(executor.getValue())
.build();
}

private Attribute toAttribute(TrackerPreheat preheat, AssignAttributeExecutor executor) {
TrackedEntityAttribute attribute =
preheat.getTrackedEntityAttribute(executor.getAttributeUid().getValue());
if (attribute == null) {
return null;
}

return Attribute.builder()
.attribute(preheat.getIdSchemes().toMetadataIdentifier(attribute))
.value(executor.getValue())
.build();
}

@Nonnull
@Override
@Transactional
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2004-2022, University of Oslo
* Copyright (c) 2004-2026, University of Oslo
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
Expand Down Expand Up @@ -38,6 +38,7 @@
import org.hisp.dhis.tracker.imports.preheat.supplier.EnrollmentsWithAtLeastOneEventSupplier;
import org.hisp.dhis.tracker.imports.preheat.supplier.EventCategoryOptionComboSupplier;
import org.hisp.dhis.tracker.imports.preheat.supplier.FileResourceSupplier;
import org.hisp.dhis.tracker.imports.preheat.supplier.OptionValueSupplier;
import org.hisp.dhis.tracker.imports.preheat.supplier.OrgUnitValueTypeSupplier;
import org.hisp.dhis.tracker.imports.preheat.supplier.PreheatStrategyScanner;
import org.hisp.dhis.tracker.imports.preheat.supplier.PreheatSupplier;
Expand All @@ -56,6 +57,7 @@ public class TrackerPreheatConfig {
private final List<Class<? extends PreheatSupplier>> preheatOrder =
List.of(
ClassBasedSupplier.class,
OptionValueSupplier.class,
DefaultsSupplier.class,
TrackedEntityEnrollmentSupplier.class,
EnrollmentsWithAtLeastOneEventSupplier.class,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2004-2022, University of Oslo
* Copyright (c) 2004-2026, University of Oslo
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
Expand Down Expand Up @@ -138,6 +138,42 @@ private Pair<String, Set<MetadataIdentifier>> toCategoryOptionComboCacheKey(
return Pair.of(categoryCombo.getUid(), categoryOptions);
}

/**
* Set of (option set id, option code) pairs confirmed to exist, populated by {@code
* OptionValueSupplier}. Only pairs actually referenced by the import payload are present here —
* this is not the full set of options for any given option set.
*
* <p>Preheated {@link org.hisp.dhis.option.OptionSet} instances deliberately carry no populated
* {@code options} collection (see {@code OptionSetMapper} for why). {@link
* #isValidOptionCode(Long, String)} and {@link #addValidOptionCode(Long, String)} are the
* supported way to check option code validity against preheated data. Do not call {@code
* OptionSet#getOptions()} on a preheated option set expecting real data — it is always empty.
*/
private final Set<Pair<Long, String>> validOptionCodes = new HashSet<>();

/**
* Option sets {@code OptionValueSupplier} attempted to resolve. Lets callers tell "this code was
* checked against the database and does not exist" apart from "this option set was never resolved
* at all", which would be an internal bug rather than user error.
*/
private final Set<Long> resolvedOptionSets = new HashSet<>();

public void addValidOptionCode(Long optionSetId, String code) {
this.validOptionCodes.add(Pair.of(optionSetId, code));
}

public boolean isValidOptionCode(Long optionSetId, String code) {
return this.validOptionCodes.contains(Pair.of(optionSetId, code));
}

public void addResolvedOptionSet(Long optionSetId) {
this.resolvedOptionSets.add(optionSetId);
}

public boolean isOptionSetResolved(Long optionSetId) {
return this.resolvedOptionSets.contains(optionSetId);
}

/**
* Check if a category option combo for given category combo and category options has been stored
* using {@link #putCategoryOptionCombo}. Returns true if null and a non-null category option
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2004-2022, University of Oslo
* Copyright (c) 2004-2026, University of Oslo
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
Expand Down Expand Up @@ -29,15 +29,19 @@
*/
package org.hisp.dhis.tracker.imports.preheat.mappers;

import java.util.List;
import org.hisp.dhis.option.Option;
import org.hisp.dhis.option.OptionSet;
import org.mapstruct.BeanMapping;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.Named;
import org.mapstruct.factory.Mappers;

/**
* {@code options} is deliberately left unmapped — mapping it would force Hibernate to fully
* materialize the {@code OptionSet.options} collection (including JSONB attribute values) on every
* preheat, regardless of how many option codes the import actually references. {@link
* org.hisp.dhis.tracker.imports.preheat.supplier.OptionValueSupplier} preheats only the specific
* {@code (option set, code)} pairs the payload references instead.
*/
@Mapper
public interface OptionSetMapper extends PreheatMapper<OptionSet> {
OptionSetMapper INSTANCE = Mappers.getMapper(OptionSetMapper.class);
Expand All @@ -47,9 +51,5 @@ public interface OptionSetMapper extends PreheatMapper<OptionSet> {
@Mapping(target = "uid")
@Mapping(target = "name")
@Mapping(target = "code")
@Mapping(target = "options", qualifiedByName = "options")
OptionSet map(OptionSet optionSet);

@Named("options")
List<Option> mapOptionValues(List<Option> options);
}
Loading
Loading