BaseActionSupport.java

/*
 * Copyright 2020 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 java.lang.reflect.ParameterizedType;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;

import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.genesys.blocks.model.EntityId;
import org.genesys.blocks.security.model.AclSid;
import org.genesys.blocks.security.persistence.AclSidPersistence;
import org.gringlobal.api.exception.InvalidApiUsageException;
import org.gringlobal.model.AbstractAction;
import org.gringlobal.model.Cooperator;
import org.gringlobal.model.CooperatorOwnedModel;
import org.gringlobal.model.QAbstractAction;
import org.gringlobal.model.community.CommunityCodeValues;
import org.gringlobal.spring.persistence.ExtendedJpaRepository;
import org.gringlobal.service.ActionService;
import org.gringlobal.service.ActionService.ActionRequest;
import org.gringlobal.service.ActionService.ActionScheduleFilter;
import org.gringlobal.service.CooperatorService;
import org.gringlobal.service.filter.ActionFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.querydsl.QuerydslPredicateExecutor;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;

import com.google.common.collect.Lists;
import com.querydsl.core.BooleanBuilder;
import com.querydsl.core.types.EntityPath;
import com.querydsl.core.types.ExpressionUtils;
import com.querydsl.core.types.Predicate;
import com.querydsl.core.types.Projections;
import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.core.types.dsl.DateTimePath;
import com.querydsl.core.types.dsl.NumberPath;
import com.querydsl.core.types.dsl.PathBuilder;
import com.querydsl.core.types.dsl.StringPath;
import com.querydsl.jpa.impl.JPAQuery;

import javax.validation.constraints.NotNull;

/**
 * The BaseActionSupport implementation of {@link ActionService}.
 * 
 * @author Matija Obreza
 * @author Maxym Borodenko
 */
@Validated
@Slf4j
public abstract class BaseActionSupport<O extends CooperatorOwnedModel, T extends AbstractAction<T>, F extends ActionFilter<F, T>, P extends ExtendedJpaRepository<T, Long>, R extends ActionRequest, ASF extends ActionScheduleFilter<T>>
		extends CRUDService2Impl<T, P> implements ActionService<T, F, R, ASF> {
	
	protected static final int OWNING_TYPE_GENERIC_INDEX = 0;
	protected static final int ACTION_TYPE_GENERIC_INDEX = 1;
	
	private final NumberPath<Long> qActionOwningEntityId;
	private final StringPath qActionNameCode;
	private final DateTimePath<Instant> qStartedDate;
	private final DateTimePath<Instant> qCompletedDate;
	private final DateTimePath<Instant> qNotBeforeDate;
	private final DateTimePath<Instant> qCreatedDate;

	private final PathBuilder<T> actionEntityPathBuilder;
	private final PathBuilder<O> owningEntityPathBuilder;
	private final PathBuilder<Cooperator> cooperatorEntityPathBuilder;

	@Autowired
	protected ExtendedJpaRepository<T, Long> actionRepository;

	@Autowired
	protected QuerydslPredicateExecutor<T> actionFinder;

	@Autowired
	private CooperatorService cooperatorService;
	
	@Autowired
	private AclSidPersistence aclSidPersistence;

	@SuppressWarnings("unchecked")
	public BaseActionSupport() {
		Class<O> owningEntityType = ((Class<O>)((ParameterizedType) getClass().getGenericSuperclass()).getActualTypeArguments()[OWNING_TYPE_GENERIC_INDEX]);
		Class<T> actionEntityType = ((Class<T>)((ParameterizedType) getClass().getGenericSuperclass()).getActualTypeArguments()[ACTION_TYPE_GENERIC_INDEX]);

		actionEntityPathBuilder = new PathBuilder<>(actionEntityType, toVariable(actionEntityType));
		owningEntityPathBuilder = actionEntityPathBuilder.get(getOwningEntityPath().getMetadata().getName(), owningEntityType);
		cooperatorEntityPathBuilder = actionEntityPathBuilder.get(QAbstractAction.abstractAction.cooperator().getMetadata().getName(), Cooperator.class);

		qActionOwningEntityId = owningEntityPathBuilder.getNumber("id", Long.class);
		qCompletedDate = actionEntityPathBuilder.getDateTime(QAbstractAction.abstractAction.completedDate.getMetadata().getName(), Instant.class);
		qStartedDate = actionEntityPathBuilder.getDateTime(QAbstractAction.abstractAction.startedDate.getMetadata().getName(), Instant.class);
		qNotBeforeDate = actionEntityPathBuilder.getDateTime(QAbstractAction.abstractAction.notBeforeDate.getMetadata().getName(), Instant.class);
		qCreatedDate = actionEntityPathBuilder.getDateTime(QAbstractAction.abstractAction.createdDate.getMetadata().getName(), Instant.class);
		qActionNameCode = actionEntityPathBuilder.getString(QAbstractAction.abstractAction.actionNameCode.getMetadata().getName());
	}

	protected abstract EntityPath<O> getOwningEntityPath();

	private String toVariable(Class<T> clazz) {
		String simpleName = clazz.getSimpleName();
		return Character.toLowerCase(simpleName.charAt(0)) + simpleName.substring(1);
	}

	@Override
	@Transactional
	public List<T> scheduleAction(R actionData) {
		if (actionData == null || CollectionUtils.isEmpty(actionData.id) || StringUtils.isBlank(actionData.actionNameCode)) {
			throw new InvalidApiUsageException();
		}

		Set<Long> entityIds = new HashSet<>(actionData.id);

		BooleanExpression booleanExpression = qActionOwningEntityId.in(entityIds)
				.and(qActionNameCode.eq(actionData.actionNameCode))
				.and(qCompletedDate.isNull());

		// find existing pending and open actions for provided items, but don't reset start date
		List<T> existingActions = Lists.newArrayList(actionFinder.findAll(booleanExpression)).stream().map(a -> {
			// remove id from the set
			entityIds.remove(a.getOwningEntityId());
			// Don't change existing action
			return a;
		}).collect(Collectors.toList());

		List<T> newActions = Lists.newArrayList(findOwningEntities(entityIds)).stream().map(entity -> {
			T action = createAction(entity, actionData);
			updateAction(action, actionData);
			action.setActionNameCode(actionData.actionNameCode);
			if (actionData.note != null) {
				action.setNote(actionData.note);
			}
			if (actionData.cooperator != null && !actionData.cooperator.isNew()) {
				action.setCooperator(cooperatorService.get(actionData.cooperator.getId()));
			} else {
				action.setCooperator(null);
			}
			if (actionData.assignee != null && !actionData.assignee.isNew()) {
				action.setAssignee(aclSidPersistence.getReferenceById(actionData.assignee.getId()));
			}
			action.setNotBeforeDate(actionData.notBeforeDate);
			action.setNotBeforeDateCode(actionData.notBeforeDateCode);
			action.setStartedDate(null); // no start date
			action.setCompletedDate(null); // no end date

			return create(action);
		}).collect(Collectors.toList());

		ArrayList<T> allActions = new ArrayList<>(existingActions);
		allActions.addAll(newActions);

		return allActions;
	}

	@Override
	@Transactional
	public List<T> startAction(R actionData) {
		if (actionData == null || CollectionUtils.isEmpty(actionData.id) || StringUtils.isBlank(actionData.actionNameCode)) {
			throw new InvalidApiUsageException();
		}

		Set<Long> entityIds = new HashSet<>(actionData.id);

		BooleanExpression booleanExpression = qActionOwningEntityId.in(entityIds)
				.and(qActionNameCode.eq(actionData.actionNameCode))
				.and(qCompletedDate.isNull());

		// find existing open actions for provided items and reset start date
		List<T> existingActions = Lists.newArrayList(actionFinder.findAll(booleanExpression)).stream().map(a -> {
			// remove id from the set
			entityIds.remove(a.getOwningEntityId());

			// reset started date
			a.setStartedDate(Instant.now());
			a.setStartedDateCode(CommunityCodeValues.DATE_FORMAT_DATETIME.value);
			return update(a);
		}).collect(Collectors.toList());

		List<T> newActions = Lists.newArrayList(findOwningEntities(entityIds)).stream().map(orderRequestItem -> {
			T action = createAction(orderRequestItem, actionData);
			updateAction(action, actionData);
			action.setActionNameCode(actionData.actionNameCode);
			if (actionData.note != null) {
				action.setNote(actionData.note);
			}
			if (actionData.cooperator != null && !actionData.cooperator.isNew()) {
				action.setCooperator(cooperatorService.get(actionData.cooperator.getId()));
			} else {
				action.setCooperator(null);
			}
			if (actionData.assignee != null && !actionData.assignee.isNew()) {
				action.setAssignee(aclSidPersistence.getReferenceById(actionData.assignee.getId()));
			}
			action.setStartedDate(Instant.now());
			action.setStartedDateCode(CommunityCodeValues.DATE_FORMAT_DATETIME.value);
			action.setCompletedDate(null);
			action.setCompletedDateCode(null);

			return create(action);
		}).collect(Collectors.toList());

		ArrayList<T> allActions = new ArrayList<>(existingActions);
		allActions.addAll(newActions);

		return allActions;
	}

	protected abstract T createAction(O owningEntity, R actionData);

	protected abstract void updateAction(T action, R actionData);

	protected abstract Iterable<O> findOwningEntities(Set<Long> id);

	@Override
	@Transactional
	public List<T> completeAction(R actionData) {
		if (actionData == null || CollectionUtils.isEmpty(actionData.id) || StringUtils.isBlank(actionData.actionNameCode)) {
			throw new InvalidApiUsageException();
		}

		BooleanExpression expression = qActionOwningEntityId.in(actionData.id)
				.and(qStartedDate.isNotNull())
				.and(qCompletedDate.isNull())
				.and(qActionNameCode.eq(actionData.actionNameCode));

		// Keep track of unique items
		Set<Long> remainingIds = new HashSet<>(actionData.id);

		List<T> actions = Lists.newArrayList(actionFinder.findAll(expression)).stream().map(action -> {
			if (!remainingIds.contains(action.getOwningEntityId())) {
				return null;
			}
			// updateAction(action, actionData); // DO NOT UPDATE OTHER DATA
			action.setCompletedDate(Instant.now());
			action.setCompletedDateCode(CommunityCodeValues.DATE_FORMAT_DATETIME.value);
			if (actionData.note != null) {
				action.setNote(actionData.note);
			}
			if (actionData.cooperator != null && !actionData.cooperator.isNew()) {
				action.setCooperator(cooperatorService.get(actionData.cooperator.getId()));
			}
			remainingIds.remove(action.getOwningEntityId());
			return update(action);
		}).filter(Objects::nonNull).collect(Collectors.toList());

		if (remainingIds.size() > 0) {
			throw new InvalidApiUsageException("Some actions could not be completed");
		}

		return actions;
	}

	@Override
	@Transactional
	public List<T> reopenAction(R actionData) {
		if (actionData == null || CollectionUtils.isEmpty(actionData.id) || StringUtils.isBlank(actionData.actionNameCode)) {
			throw new InvalidApiUsageException();
		}

		// Keep track of unique items
		Set<Long> remainingIds = new HashSet<>(actionData.id);
		
		BooleanExpression expression = qActionOwningEntityId.in(actionData.id)
				.and(qStartedDate.isNotNull())
				.and(qCompletedDate.isNotNull())
				.and(qActionNameCode.eq(actionData.actionNameCode));

		List<T> latestActions = new ArrayList<>();
		actionFinder.findAll(expression, qCompletedDate.desc()).forEach(action -> {
			if (latestActions.stream().noneMatch(a -> a.getOwningEntityId().equals(action.getOwningEntityId()))) {
				latestActions.add(action);
				remainingIds.remove(action.getOwningEntityId());
			}
		});

		if (remainingIds.size() > 0) {
			throw new InvalidApiUsageException("Some actions could not be reopened.");
		}

		return latestActions.stream().map(action -> {
			// updateAction(action, actionData); // DO NOT UPDATE OTHER DATA
			action.setCompletedDate(null);
			action.setCompletedDateCode(null);
			action.setStartedDate(Instant.now());
			action.setStartedDateCode(CommunityCodeValues.DATE_FORMAT_DATETIME.value);
			if (actionData.note != null) {
				action.setNote(actionData.note);
			}
			return actionRepository.save(action);
		}).collect(Collectors.toList());
	}

	@Override
	@Transactional
	public T update(T updated, T target) {
		target.apply(updated);

		log.debug("Update action. Input data {}", updated);
		T saved = actionRepository.save(target);
		saved.lazyLoad();

		return saved;
	}

	@Override
	@Transactional
	public T updateFast(T updated, T target) {
		target.apply(updated);
		return actionRepository.save(target);
	}

	@Override
	@Transactional(readOnly = true)
	public Page<T> listActions(F filter, Pageable page) {
		var result = actionFinder.findAll(filter.buildPredicate(), page);
		initializeActionDetails(result.getContent());
		return result;
	}

	@Override
	@Transactional(readOnly = true)
	public long countActions(F filter) {
		return actionFinder.count(filter.buildPredicate());
	}

	@Override
	public Page<T> listCompletedActions(ASF filter, Pageable page) {
		return actionRepository.findAll(buildQueryCompleted(filter)
				// fetch join owning entity
				.join(owningEntityPathBuilder).fetchJoin()
				// fetch join cooperator
				.leftJoin(cooperatorEntityPathBuilder).fetchJoin(), page);
	}

	@Override
	public Page<T> listInProgressActions(ASF filter, Pageable page) {
		return actionRepository.findAll(buildQueryInProgress(filter)
				// fetch join owning entity
				.join(owningEntityPathBuilder).fetchJoin()
				// fetch join cooperator
				.leftJoin(cooperatorEntityPathBuilder).fetchJoin(), page);
	}

	@Override
	public List<T> listInProgressActions(R actionData) {
		BooleanExpression expression = qActionOwningEntityId.in(actionData.id)
				.and(qStartedDate.isNotNull())
				.and(qCompletedDate.isNull())
				.and(qActionNameCode.eq(actionData.actionNameCode));

		return Lists.newArrayList(actionFinder.findAll(expression));
	}

	@Override
	public Page<T> listScheduledActions(ASF filter, Pageable page) {
		return actionRepository.findAll(buildQueryScheduled(filter)
				// fetch join owning entity
				.join(owningEntityPathBuilder).fetchJoin()
				// fetch join cooperator
				.leftJoin(cooperatorEntityPathBuilder).fetchJoin(), page);
	}

	@Override
	public Page<T> listAddedActions(ASF filter, Pageable page) {
		return actionRepository.findAll(buildQueryAdded(filter)
				// fetch join owning entity
				.join(owningEntityPathBuilder).fetchJoin()
				// fetch join cooperator
				.leftJoin(cooperatorEntityPathBuilder).fetchJoin(), page);
	}

	/**
	 * Override to initialize action details. Example:
	 * 
	 * <pre>
	 * actions.forEach(action -> {
	 *  Hibernate.initialize(action.getInventory());
	 *  Hibernate.initialize(action.getInventory().getAccession());
	 * });
	 * </pre>
	 * 
	 * @param actions
	 */
	protected abstract void initializeActionDetails(List<T> actions);

	@Override
	public Page<T> listCreatedActions(ASF filter, Pageable page) {
		return actionRepository.findAll(buildQueryCreated(filter)
				// fetch join owning entity
				.join(owningEntityPathBuilder).fetchJoin()
				// fetch join cooperator
				.leftJoin(cooperatorEntityPathBuilder).fetchJoin(), page);
	}

	@Override
	public Page<T> listOverdueActions(ASF filter, Pageable page) {
		return actionRepository.findAll(buildQueryOverdue(filter)
				// fetch join owning entity
				.join(owningEntityPathBuilder).fetchJoin()
				// fetch join cooperator
				.leftJoin(cooperatorEntityPathBuilder).fetchJoin(), page);
	}

	@Override
	public ActionScheduleOverview actionScheduleOverview(ASF filter) {
		NumberPath<Long> qActionId = actionEntityPathBuilder.getNumber("id", Long.class);

		// Completed
		JPAQuery<OverviewRow> completedQuery = buildQueryCompleted(filter)
				.select(Projections.constructor(OverviewRow.class, qActionNameCode, qActionId.countDistinct())).groupBy(qActionNameCode);
		// In progress
		JPAQuery<OverviewRow> inProgressQuery = buildQueryInProgress(filter)
				.select(Projections.constructor(OverviewRow.class, qActionNameCode, qActionId.countDistinct())).groupBy(qActionNameCode);
		// Scheduled
		JPAQuery<OverviewRow> scheduledQuery = buildQueryScheduled(filter)
				.select(Projections.constructor(OverviewRow.class, qActionNameCode, qActionId.countDistinct())).groupBy(qActionNameCode);
		// New, but unscheduled actions
		JPAQuery<OverviewRow> addedQuery = buildQueryAdded(filter)
				.select(Projections.constructor(OverviewRow.class, qActionNameCode, qActionId.countDistinct())).groupBy(qActionNameCode);
		// Created
		JPAQuery<OverviewRow> createdQuery = buildQueryCreated(filter)
				.select(Projections.constructor(OverviewRow.class, qActionNameCode, qActionId.countDistinct())).groupBy(qActionNameCode);
		// Overdue
		JPAQuery<OverviewRow> overdueQuery = buildQueryOverdue(filter)
				.select(Projections.constructor(OverviewRow.class, qActionNameCode, qActionId.countDistinct())).groupBy(qActionNameCode);

		var overview = new ActionScheduleOverview();
		overview.completed = completedQuery.fetch().stream().collect(Collectors.toMap(OverviewRow::getActionName, OverviewRow::getCount));
		overview.inProgress = inProgressQuery.fetch().stream().collect(Collectors.toMap(OverviewRow::getActionName, OverviewRow::getCount));
		overview.scheduled = scheduledQuery.fetch().stream().collect(Collectors.toMap(OverviewRow::getActionName, OverviewRow::getCount));
		overview.added = addedQuery.fetch().stream().collect(Collectors.toMap(OverviewRow::getActionName, OverviewRow::getCount));
		overview.created = createdQuery.fetch().stream().collect(Collectors.toMap(OverviewRow::getActionName, OverviewRow::getCount));
		overview.overdue = overdueQuery.fetch().stream().collect(Collectors.toMap(OverviewRow::getActionName, OverviewRow::getCount));

		return overview;
	}

	@Override
	@Transactional
	public List<T> assignActions(@NotNull Map<Long, Long> actionAssigneeMap) {
		if (actionAssigneeMap.isEmpty()) {
			return new ArrayList<>();
		}
		var actions = actionRepository
			.findAllById(actionAssigneeMap.keySet())
			.stream().collect(Collectors.toMap(EntityId::getId, action -> action));
		var sids = aclSidPersistence
			.findAllById(actionAssigneeMap.values().stream().filter(Objects::nonNull).collect(Collectors.toSet()))
			.stream().collect(Collectors.toMap(AclSid::getId, sid -> sid));

		var actionsToSave = new ArrayList<T>();
		
		for (var actionAssignee : actionAssigneeMap.entrySet()) {
			var actionId = actionAssignee.getKey();
			var sidId = actionAssignee.getValue();
			var action = actions.get(actionId);
			// Check if action or sid(if not null from request) not found
			if (action == null || (sidId != null && !sids.containsKey(sidId))) {
				continue;
			}
			action.setAssignee(sidId == null ? null : sids.get(sidId));
			actionsToSave.add(action);
		}
		
		return actionRepository.saveAllAndFlush(actionsToSave);
	}

	protected static class OverviewRow {
		public String actionName;
		public Number count;

		public OverviewRow(String actionCodeName, Number count) {
			this.actionName = actionCodeName;
			this.count = count;
		}

		public String getActionName() {
			return actionName;
		}

		public Number getCount() {
			return count;
		}
	}

	private JPAQuery<T> buildQueryCompleted(ASF filter) {
		BooleanBuilder predicate = new BooleanBuilder();
		// completedDate >= DATE1 and completedDate < DATE2
		predicate.and(
			qCompletedDate.goe(filter.fromInclusive).and(qCompletedDate.lt(filter.toExclusive))
		);
		return buildQuery(filter, predicate);
	}

	private JPAQuery<T> buildQueryInProgress(ASF filter) {
		BooleanBuilder predicate = new BooleanBuilder();
		// startedDate < DATE2 and (completedDate is null or completedDate > DATE2)
		predicate.and(qStartedDate.before(filter.toExclusive)
			.andAnyOf(
				qCompletedDate.isNull(),
				qCompletedDate.gt(filter.toExclusive)
			)
		);
		return buildQuery(filter, predicate);
	}

	private JPAQuery<T> buildQueryScheduled(ASF filter) {
		// notBeforeDate >= DATE1 and notBeforeDate < DATE2
		var predicate = qNotBeforeDate.goe(filter.fromInclusive).and(qNotBeforeDate.lt(filter.toExclusive));
		return buildQuery(filter, predicate);
	}

	private JPAQuery<T> buildQueryAdded(ASF filter) {
		// notBeforeDate IS NULL and createdDate >= DATE1 and createdDate < DATE2
		var predicate = qNotBeforeDate.isNull().and(qCreatedDate.goe(filter.fromInclusive).and(qCreatedDate.lt(filter.toExclusive)));
		return buildQuery(filter, predicate);
	}

	private JPAQuery<T> buildQueryCreated(ASF filter) {
		// createdDate >= DATE1 and createdDate < DATE2
		var predicate = qCreatedDate.goe(filter.fromInclusive).and(qCreatedDate.lt(filter.toExclusive));
		return buildQuery(filter, predicate);
	}

	private JPAQuery<T> buildQueryOverdue(ASF filter) {
		// startedDate IS NULL and completedDate IS NULL and ((notBeforeDate IS NULL and createdDate < DATE2) or (notBeforeDate < DATE2))
		var predicate = qStartedDate.isNull().and(qCompletedDate.isNull()).andAnyOf(
				// notBeforeDate IS NULL and createdDate < DATE2
				qNotBeforeDate.isNull().and(qCreatedDate.lt(filter.toExclusive)),
				// notBeforeDate < DATE2
				qNotBeforeDate.lt(filter.toExclusive));
		return buildQuery(filter, predicate);
	}

	private JPAQuery<T> buildQuery(ASF filter, Predicate datePredicate) {
		assert datePredicate != null;

		List<Predicate> predicates = new ArrayList<>();
		// set date predicate
		predicates.add(datePredicate);

		applyOwningEntityFilter(filter, owningEntityPathBuilder.toString(), predicates);

		if (CollectionUtils.isNotEmpty(filter.actionCode)) {
			BooleanBuilder predicate = new BooleanBuilder();
			predicate.and(qActionNameCode.in(filter.actionCode));
			predicates.add(predicate);
		}
		return jpaQueryFactory.selectFrom(actionEntityPathBuilder)
				// avoid cross join
				.join(owningEntityPathBuilder)
				// apply predicates
				.where(ExpressionUtils.allOf(predicates));
	}

	protected abstract void applyOwningEntityFilter(ASF filter, String alias, List<Predicate> predicates);

}