WorkflowServiceImpl.java
/*
* Copyright 2024 Global Crop Diversity Trust
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.gringlobal.service.impl;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import lombok.extern.slf4j.Slf4j;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.genesys.blocks.util.CurrentApplicationContext;
import org.gringlobal.api.exception.InvalidApiUsageException;
import org.gringlobal.component.elastic.AppContextHelper;
import org.gringlobal.model.AbstractAction;
import org.gringlobal.model.AccessionAction;
import org.gringlobal.model.InventoryAction;
import org.gringlobal.model.InventoryViabilityAction;
import org.gringlobal.model.OrderRequestAction;
import org.gringlobal.model.OrderRequestItemAction;
import org.gringlobal.model.workflow.QWorkflowTransition;
import org.gringlobal.model.workflow.Workflow;
import org.gringlobal.model.workflow.WorkflowActionStep;
import org.gringlobal.model.workflow.WorkflowEndStep;
import org.gringlobal.model.workflow.WorkflowStartStep;
import org.gringlobal.model.workflow.WorkflowStep;
import org.gringlobal.model.workflow.WorkflowTransition;
import org.gringlobal.persistence.WorkflowRepository;
import org.gringlobal.persistence.WorkflowStepRepository;
import org.gringlobal.service.AccessionActionService;
import org.gringlobal.service.ActionService;
import org.gringlobal.service.InventoryActionService;
import org.gringlobal.service.InventoryViabilityActionService;
import org.gringlobal.service.OrderRequestActionService;
import org.gringlobal.service.OrderRequestItemActionService;
import org.gringlobal.service.WorkflowService;
import org.gringlobal.service.filter.WorkflowFilter;
import org.hibernate.Hibernate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.expression.Expression;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import javax.validation.ValidationException;
/**
* The Class WorkflowServiceImpl.
*/
@Service
@Transactional(readOnly = true)
@Validated
@PreAuthorize("hasAuthority('GROUP_ADMINS')")
@Slf4j
public class WorkflowServiceImpl extends FilteredCRUDService2Impl<Workflow, WorkflowFilter, WorkflowRepository> implements WorkflowService {
private final Cache<String, Expression> parsedExpressions = CacheBuilder.newBuilder().maximumSize(30).expireAfterAccess(10, TimeUnit.HOURS).build();
@Autowired
private WorkflowStepRepository workflowStepRepository;
private Map<String, ActionService> actionServices;
private final ExpressionParser expressionParser = new SpelExpressionParser();
@Override
public void afterPropertiesSet() throws Exception {
super.afterPropertiesSet();
actionServices = new HashMap<>();
actionServices.put(AccessionAction.class.getName(), CurrentApplicationContext.getContext().getBean(AccessionActionService.class));
actionServices.put(InventoryAction.class.getName(), CurrentApplicationContext.getContext().getBean(InventoryActionService.class));
actionServices.put(InventoryViabilityAction.class.getName(), CurrentApplicationContext.getContext().getBean(InventoryViabilityActionService.class));
actionServices.put(OrderRequestAction.class.getName(), CurrentApplicationContext.getContext().getBean(OrderRequestActionService.class));
actionServices.put(OrderRequestItemAction.class.getName(), CurrentApplicationContext.getContext().getBean(OrderRequestItemActionService.class));
}
@Override
public Workflow create(Workflow source) {
return createFast(source);
}
@Override
public Workflow createFast(Workflow source) {
assert (CollectionUtils.isEmpty(source.getTransitions()));
Workflow workflow = new Workflow();
workflow.apply(source);
if (!actionServices.containsKey(workflow.getActionType())) {
throw new InvalidApiUsageException("Invalid workflow action type");
}
workflow = repository.save(source);
return createStartEndWorkflowSteps(workflow);
}
private Workflow createStartEndWorkflowSteps(Workflow workflow) {
WorkflowStartStep startStep = new WorkflowStartStep();
startStep.setWorkflow(workflow);
WorkflowEndStep endStep = new WorkflowEndStep();
endStep.setWorkflow(workflow);
workflowStepRepository.saveAll(List.of(startStep, endStep));
return repository.getReferenceById(workflow.getId());
}
@Override
public Workflow update(Workflow updated, Workflow target) {
return updateFast(updated, target);
}
@Override
@Transactional
public Workflow updateTransitions(Workflow target, List<WorkflowTransition> transitions) {
var update = get(target.getId());
if (CollectionUtils.isEmpty(transitions)) {
update.getTransitions().clear();
} else {
validateWorkflowTransitions(transitions, update);
if (update.getTransitions() == null) {
update.setTransitions(transitions);
} else {
update.getTransitions().removeIf(t -> {
boolean shouldRemove = /* By origin+target */
null == transitions.stream()
.filter(tt -> Objects.equals(tt.getOrigin().getId(), t.getOrigin().getId()) && Objects.equals(tt.getTarget().getId(), t.getTarget().getId()))
// Find match in existing transitions
.findFirst().orElse(null);
// if (shouldRemove) {
// log.warn("Removing {}", t);
// } else {
// log.warn("Keeping {}", t);
// }
return shouldRemove;
});
update.getTransitions().forEach(t -> {
transitions.stream()
.filter(tt ->
Objects.equals(tt.getOrigin().getId(), t.getOrigin().getId())
&& Objects.equals(tt.getTarget().getId(), t.getTarget().getId())
&& !StringUtils.equals(tt.getCondition(), t.getCondition())
)
// Find match in existing transitions
.findFirst()
.ifPresent(updated -> t.setCondition(updated.getCondition()));
});
var toAdd = transitions.stream()
// Add missing transitions
.filter(t -> {
boolean shouldAdd = /* By origin + target */
null == update.getTransitions().stream()
.filter(tt -> Objects.equals(tt.getOrigin().getId(), t.getOrigin().getId()) && Objects.equals(tt.getTarget().getId(), t.getTarget().getId()))
// Fiund match in existing transitions
.findFirst().orElse(null);
// if (shouldAdd) {
// log.warn("Adding {}", t);
// } else {
// log.warn("Ignoring {}", t);
// }
return shouldAdd;
})
// Collect
.collect(Collectors.toList());
update.getTransitions().addAll(toAdd);
}
}
return repository.save(update);
}
@Override
public void validateWorkflowTransitions(List<WorkflowTransition> transitions, Workflow workflow) {
transitions.forEach(transition -> {
assert (Objects.equals(transition.getOrigin().getWorkflow().getId(), workflow.getId()));
assert (Objects.equals(transition.getTarget().getWorkflow().getId(), workflow.getId()));
assert (!Objects.equals(transition.getOrigin().getId(), transition.getTarget().getId()));
});
var conditions = transitions.stream()
.map(WorkflowTransition::getCondition)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
if (!conditions.isEmpty()) {
for (String condition : conditions) {
parseExpression(condition);
}
}
// Check unlinked transitions
var originSteps = transitions.stream()
.map(WorkflowTransition::getOrigin)
.filter(step -> Hibernate.unproxy(step) instanceof WorkflowActionStep)
.collect(Collectors.toSet());
var targetSteps = transitions.stream()
.map(WorkflowTransition::getTarget)
.filter(step -> Hibernate.unproxy(step) instanceof WorkflowActionStep)
.collect(Collectors.toSet());
for (WorkflowStep originStep : originSteps) {
if (!targetSteps.contains(originStep)) {
throw new InvalidApiUsageException("All steps must be present as origins and as targets in transitions");
}
}
for (WorkflowStep targetStep : targetSteps) {
if (!originSteps.contains(targetStep)) {
throw new InvalidApiUsageException("All steps must be present as origins and as targets in transitions");
}
}
var startTransitions = transitions.stream()
.filter(transition -> Hibernate.unproxy(transition.getOrigin()) instanceof WorkflowStartStep)
.collect(Collectors.toList());
if (startTransitions.isEmpty() || startTransitions.stream().map(WorkflowTransition::getOrigin).distinct().count() > 1) {
throw new InvalidApiUsageException("Transitions must have one workflow start step.");
}
checkStepsIsNotEquals(startTransitions);
startTransitions.forEach(startTransition -> validateTransitions(transitions, startTransition.getTarget(), 0));
}
private Expression parseExpression(String condition) {
try {
return parsedExpressions.get("parsed_spel_expr:" + condition, () -> expressionParser.parseExpression(condition));
} catch (Exception e) {
throw new ValidationException("Invalid SpEL expression", e);
}
}
private void validateTransitions(List<WorkflowTransition> transitions, WorkflowStep targetStep, int transitionNumber) {
// Check if target is end step
if ((Hibernate.unproxy(targetStep) instanceof WorkflowEndStep)) {
return;
}
// The longest linked list of steps must be smaller than the transitions size.
if (transitionNumber + 1 == transitions.size()) {
if (!(Hibernate.unproxy(targetStep) instanceof WorkflowEndStep)) {
// Cyclic links ?
throw new InvalidApiUsageException("Cyclic transition links");
}
} else {
// Find all transitions with origin = targetStep
var targetTransitions = transitions.stream()
.filter(transition -> transition.getOrigin().equals(targetStep))
.collect(Collectors.toList());
if (targetTransitions.size() < 1) {
throw new InvalidApiUsageException("The flow chain is broken");
}
checkStepsIsNotEquals(targetTransitions);
targetTransitions.forEach(transition -> validateTransitions(transitions, transition.getTarget(), transitionNumber + 1));
}
}
private void checkStepsIsNotEquals(List<WorkflowTransition> transitions) {
var targetSteps = transitions.stream()
.filter(transition -> Hibernate.unproxy(transition.getTarget()) instanceof WorkflowActionStep)
.map(transition -> (WorkflowActionStep) Hibernate.unproxy(transition.getTarget()))
.collect(Collectors.toList());
var distinctSteps = targetSteps.stream()
.map(step -> String.valueOf(step.getActionAssignee().getId()).concat(step.getActionNameCode()))
.distinct()
.count();
if (distinctSteps != targetSteps.size()) {
throw new InvalidApiUsageException("Equal target steps are not allowed");
}
}
@Override
public Workflow updateFast(Workflow updated, Workflow target) {
target.apply(updated); // Doesn't update List<WorkflowTransitions>
return repository.save(target);
}
@Override
public Workflow remove(Workflow entity) {
return super.remove(entity);
}
@Transactional
@Override
public void createNextStepAction(AbstractAction<?> completedAction) {
if (completedAction.getWorkflowStep() == null) {
return;
}
var completedStep = workflowStepRepository.getReferenceById(completedAction.getWorkflowStep().getId());
ActionService service = actionServices.get(completedStep.getWorkflow().getActionType()); // This will pull workflow from the database
if (service != null) {
var targetTransitions = jpaQueryFactory.select(QWorkflowTransition.workflowTransition).from(QWorkflowTransition.workflowTransition).where(QWorkflowTransition.workflowTransition.origin().eq(completedStep)).fetch();
var targetSteps = targetTransitions.stream()
.filter(transition -> {
var condition = transition.getCondition();
if (condition != null) {
var action = service.get(completedAction.getId());
return parseExpression(condition).getValue(action, Boolean.class);
}
return true;
})
.map(WorkflowTransition::getTarget)
.collect(Collectors.toList());
targetSteps.stream()
.map(nextStep -> Hibernate.unproxy(nextStep))
.filter(nextStep -> nextStep instanceof WorkflowActionStep)
.filter(nextStep -> !Objects.equals(nextStep, completedStep)) // Just in case!
.forEach(nextStep -> {
log.info("Completed step {} starting {}", completedStep, nextStep);
service.createNextWorkflowStepAction((WorkflowActionStep) nextStep, completedAction);
});
}
}
@Override
public <T extends AbstractAction<T>> List<T> startWorkflow(long workflowId, Set<Long> owningEntityIds, ActionCreator<T> actionCreator) {
var loadedWorkflow = load(workflowId);
var transitions = loadedWorkflow.getTransitions();
var stepWithTransition = transitions.stream()
.filter(transition -> Hibernate.unproxy(transition.getOrigin()) instanceof WorkflowStartStep && Hibernate.unproxy(transition.getTarget()) instanceof WorkflowActionStep)
.collect(Collectors.toMap((t) -> (WorkflowActionStep)Hibernate.unproxy(t.getTarget()), (t) -> t));
if (stepWithTransition.isEmpty()) {
throw new IllegalArgumentException("The workflow does not contain steps to complete");
}
// Build actions by ActionCreator.buildActions
var actions = actionCreator.buildActions(new ArrayList<>(stepWithTransition.keySet()));
// Filter by condition and return to persist
return actions.stream().filter(action -> {
var transition = stepWithTransition.get(action.getWorkflowStep());
var condition = transition.getCondition();
if (condition != null) {
return parseExpression(condition).getValue(action, Boolean.class);
}
return true;
}).collect(Collectors.toList());
}
public interface ActionCreator<T extends AbstractAction<T>> {
List<T> buildActions(List<WorkflowActionStep> steps);
}
@Service
@Transactional(readOnly = true)
@Validated
@PreAuthorize("hasAuthority('GROUP_ADMINS')")
@Slf4j
public static class WorkflowStepServiceImpl extends CRUDService2Impl<WorkflowStep, WorkflowStepRepository> implements WorkflowStepService {
@Autowired
private WorkflowRepository workflowRepository;
@Autowired
private WorkflowService workflowService;
@Override
@Transactional
public WorkflowStep createFast(WorkflowStep source) {
if (source instanceof WorkflowStartStep || source instanceof WorkflowEndStep) {
throw new InvalidApiUsageException("WorkflowStartStep and WorkflowEndStep are automatically created along with the workflow.");
}
var workflow = workflowRepository.getReferenceById(source.getWorkflow().getId());
String actionCodeGroup;
try {
Class<AbstractAction<?>> actionTypeClass = (Class<AbstractAction<?>>) Class.forName(workflow.getActionType());
actionCodeGroup = actionTypeClass.getDeclaredConstructor().newInstance().getActionGroupName();
} catch (Exception e) {
throw new InvalidApiUsageException("Invalid workflow action type");
}
if (!AppContextHelper.validateCodeValue(actionCodeGroup, ((WorkflowActionStep) source).getActionNameCode())) {
throw new InvalidApiUsageException("Invalid action code value");
}
return repository.save(source);
}
@Override
@Transactional
public WorkflowStep create(WorkflowStep source) {
return createFast(source);
}
@Override
@Transactional
public WorkflowStep updateFast(WorkflowStep updated, WorkflowStep target) {
target.apply(updated);
var saved = repository.saveAndFlush(target);
var workflow = workflowRepository.getReferenceById(saved.getWorkflow().getId());
var transitions = workflow.getTransitions();
if (transitions.stream().anyMatch(t -> t.getOrigin().equals(saved) || t.getTarget().equals(saved))) {
workflowService.validateWorkflowTransitions(transitions, workflow);
}
return saved;
}
@Override
@Transactional
public WorkflowStep update(WorkflowStep updated, WorkflowStep target) {
return updateFast(updated, target);
}
}
}