CropServiceImpl.java

/**
 * Copyright 2014 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.genesys.server.service.impl;

import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;

import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.time.StopWatch;
import org.genesys.filerepository.InvalidRepositoryPathException;
import org.genesys.filerepository.model.ImageGallery;
import org.genesys.filerepository.model.RepositoryFolder;
import org.genesys.filerepository.service.ImageGalleryService;
import org.genesys.filerepository.service.RepositoryService;
import org.genesys.server.api.v1.mapper.APIv1Mapper;
import org.genesys.server.component.security.AsAdminInvoker;
import org.genesys.server.exception.InvalidApiUsageException;
import org.genesys.server.exception.NotFoundElement;
import org.genesys.server.exception.SearchException;
import org.genesys.server.model.dataset.Dataset;
import org.genesys.server.model.filters.DatasetFilter;
import org.genesys.server.model.filters.DescriptorListFilter;
import org.genesys.server.model.genesys.Accession;
import org.genesys.server.model.genesys.QAccession;
import org.genesys.server.model.impl.Article;
import org.genesys.server.model.impl.Crop;
import org.genesys.server.model.impl.CropLang;
import org.genesys.server.model.impl.DiversityTree;
import org.genesys.server.model.impl.Subset;
import org.genesys.server.model.traits.DescriptorList;
import org.genesys.server.persistence.AccessionRepository;
import org.genesys.server.persistence.CropLangRepository;
import org.genesys.server.persistence.CropRepository;
import org.genesys.server.service.AccessionService;
import org.genesys.server.service.ArticleService;
import org.genesys.server.service.ArticleTranslationService;
import org.genesys.server.service.CRMException;
import org.genesys.server.service.ContentService;
import org.genesys.server.service.CropService;
import org.genesys.server.service.CropTranslationService;
import org.genesys.server.service.CropTranslationService.TranslatedCrop;
import org.genesys.server.service.DatasetService;
import org.genesys.server.service.DescriptorListService;
import org.genesys.server.service.DiversityTreeService;
import org.genesys.server.service.ElasticsearchService;
import org.genesys.server.service.HtmlSanitizer;
import org.genesys.server.service.SubsetService;
import org.genesys.server.service.TranslatorService;
import org.genesys.server.service.TranslatorService.FormattedText;
import org.genesys.server.service.TranslatorService.TextFormat;
import org.genesys.server.service.filter.AccessionFilter;
import org.genesys.server.service.filter.CropFilter;
import org.genesys.server.service.filter.DiversityTreeFilter;
import org.genesys.server.service.filter.SubsetFilter;
import org.genesys.server.service.worker.AccessionProcessor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.context.annotation.Lazy;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;

import com.google.common.collect.Sets;
import com.querydsl.core.BooleanBuilder;

@Service
@Transactional(readOnly = true)
@Validated
public class CropServiceImpl
	extends FilteredTranslatedCRUDServiceImpl<Crop, CropLang, CropTranslationService.TranslatedCrop, CropFilter, CropRepository>
	implements CropService {

	private static final String CACHE_CROPS = "hibernate.org.genesys.server.model.impl.Crop.cache";

	public static final Logger LOG = LoggerFactory.getLogger(CropServiceImpl.class);

	@Lazy
	@Autowired
	private SubsetService subsetService;
	@Lazy
	@Autowired
	private DatasetService datasetService;
	@Lazy
	@Autowired
	private DiversityTreeService treeService;

	@Autowired
	private DescriptorListService descriptorListService;

	@Autowired
	CropRepository cropRepository;

	@Autowired
	private HtmlSanitizer htmlSanitizer;

	@Autowired
	private ArticleService articleService;

	@Autowired
	private ContentService contentServiceV1;

	@Autowired
	private AccessionService accessionService;

	@Autowired(required = false)
	private ElasticsearchService elasticsearchService;

	@Autowired
	private ImageGalleryService imageGalleryService;

	@Autowired
	private AccessionProcessor accessionProcessor;

	@Autowired
	private AccessionRepository accessionRepository;

	@Autowired
	protected AsAdminInvoker asAdminInvoker;

	@Autowired
	private RepositoryService repositoryService;

	@Autowired(required = false)
	private TranslatorService translatorService;
	
	@Autowired
	private APIv1Mapper apIv1Mapper;

	@Component(value = "CropTranslationSupport")
	protected static class CropTranslationSupport
		extends BaseTranslationSupport<Crop, CropLang, CropTranslationService.TranslatedCrop, CropFilter, CropLangRepository>
		implements CropTranslationService {

		@Override
		@Transactional
		@PreAuthorize("hasRole('ADMINISTRATOR') or hasRole('CONTENTMANAGER')")
		@CacheEvict(allEntries = true, value = CACHE_CROPS)
		public CropLang create(CropLang source) {
			return super.create(source);
		}

		@Override
		@Transactional
		@PreAuthorize("hasRole('ADMINISTRATOR') or hasRole('CONTENTMANAGER')")
		@CacheEvict(allEntries = true, value = CACHE_CROPS)
		public CropLang update(CropLang updated, CropLang target) {
			return super.update(updated, target);
		}

		@Override
		@Transactional
		@PreAuthorize("hasRole('ADMINISTRATOR') or hasRole('CONTENTMANAGER')")
		@CacheEvict(allEntries = true, value = CACHE_CROPS)
		public CropLang remove(CropLang entity) {
			return super.remove(entity);
		}

		@Override
		@Transactional
		@PreAuthorize("hasRole('ADMINISTRATOR') or hasRole('CONTENTMANAGER')")
		@CacheEvict(allEntries = true, value = CACHE_CROPS)
		public CropLang upsertLang(Crop entity, CropLang input) {
			return super.upsertLang(entity, input);
		}
	}

	/**
	 * @param shortName the crop name ("groundnuts") as provided by partners, but
	 * can also be a list of names ("peanut,goober,groundnut")
	 */
	@Override
	// We cache null-results so they're not looked up again
	@Cacheable(value = CACHE_CROPS, key = "#shortName", unless = "#shortName == null")
	public Crop getCrop(String shortName) {
		if (StringUtils.isBlank(shortName)) {
			return null;
		}


		for (String cropName : shortName.split(",;")) {
			cropName = StringUtils.trimToNull(cropName);
			if (cropName != null) {
				Crop crop = cropRepository.findByShortName(cropName);

				// Find crop by alias when null
				if (crop == null) {
					crop = cropRepository.findByOtherNames(cropName);
				}

				if (crop != null) {
					// Lazy load otherNames
					if (crop.getOtherNames() != null) {
						crop.getOtherNames().size();
					}
					return crop;
				}
			}
		}

		return null;
	}

	@Override
	public CropDetails getDetails(String shortName, Locale locale) throws InvalidRepositoryPathException {
		LOG.debug("Getting crop details for {}", shortName);
		StopWatch stopWatch = StopWatch.createStarted();
		Crop crop = getCrop(shortName);
		if (crop == null) {
			throw new NotFoundElement("No crop: " + shortName);
		}
		LOG.trace("got crop after {}ms", stopWatch.getTime());
		ArticleTranslationService.TranslatedArticle article = articleService.getArticle(crop, ContentService.ENTITY_BLURB_SLUG, locale);
		LOG.trace("got article after {}ms", stopWatch.getTime());
		AccessionFilter byCrop = new AccessionFilter()
			.crop(Sets.newHashSet(crop.getShortName()));
		Long accessionCount = null;
		try {
			accessionCount = accessionService.countAccessions(byCrop);
		} catch (SearchException e) {
			LOG.warn("Error occurred during count", e);
		}
		LOG.trace("got count after {}ms", stopWatch.getTime());

		ImageGallery imageGallery = imageGalleryService.loadImageGallery(Paths.get("/crop", crop.getShortName(), "covers"));
		if (imageGallery != null) {
			if (imageGallery.getImages() != null) {
				imageGallery.getImages().size();
			}
		}
		LOG.trace("got covers after {}ms", stopWatch.getTime());

		RepositoryFolder folder = repositoryService.getFolder(Paths.get("/crop", crop.getShortName()));

		Map<String, ElasticsearchService.TermResult> overview = getOverviewData(byCrop);
		LOG.trace("got overview after {}ms", stopWatch.getTime());


		PageRequest pageRequest = PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "lastModifiedDate"));

		SubsetFilter subsetFilter = new SubsetFilter()
			.crops(Collections.singleton(crop.getShortName()));
		List<Subset> recentSubsets = null;
		try {
			recentSubsets = subsetService.list(subsetFilter, pageRequest).getContent();
		} catch (SearchException e) {
			LOG.error("Error occurred during search", e);
		}

		DatasetFilter datasetFilter = new DatasetFilter()
			.crops(Collections.singleton(crop.getShortName()));
		List<Dataset> recentDatasets = null;
		try {
			recentDatasets = datasetService.list(datasetFilter, pageRequest).getContent();
		} catch (SearchException e) {
			LOG.error("Error occurred during search", e);
		}

		DescriptorListFilter descriptorListFilter = new DescriptorListFilter()
			.crop(Collections.singleton(crop.getShortName()));
		List<DescriptorList> recentDescriptorLists = null;
		try {
			recentDescriptorLists = descriptorListService.list(descriptorListFilter, pageRequest).getContent();
		} catch (SearchException e) {
			LOG.error("Error occurred during search", e);
		}

		DiversityTreeFilter treeFilter = new DiversityTreeFilter()
			.crop(crop.getShortName());
		List<DiversityTree> diversityTrees = null;
		try {
			diversityTrees = treeService.list(treeFilter, pageRequest).getContent();
		} catch (SearchException e) {
			LOG.error("Error occurred during search", e);
		}

		return new CropDetails.Builder(loadTranslated(crop.getId()))
			.blurb(article)
			.accessionCount(accessionCount)
			.imageGallery(imageGallery)
			.folder(folder)
			.overview(overview)
			.recentDatasets(recentDatasets)
			.recentDescriptorLists(recentDescriptorLists)
			.recentSubsets(recentSubsets)
			.diversityTrees(diversityTrees)
			.build();
	}

	@PreAuthorize("hasRole('ADMINISTRATOR')")
	@Override
	@Transactional
	@CacheEvict(allEntries = true, value = CACHE_CROPS)
	public Crop remove(Crop crop) {
		super.remove(crop);
		crop.setId(null);
		return crop;
	}

	/** APIv1 compatible updateBlurb */
	@Override
	@PreAuthorize("hasRole('ADMINISTRATOR') or hasRole('CONTENTMANAGER')")
	@Transactional(readOnly = false)
	public Article updateBlurb(Crop crop, String title, String summary, String textBody, Locale locale) throws CRMException {
		//isTemplate = false because it's crop
		return apIv1Mapper.map(contentServiceV1.updateArticle(crop, ContentService.ENTITY_BLURB_SLUG, title, summary, textBody, locale));
	}

	@Override
	@PreAuthorize("hasRole('ADMINISTRATOR') or hasRole('CONTENTMANAGER')")
	@Transactional(readOnly = false)
	@CacheEvict(value = CACHE_CROPS, allEntries = true)
	public Crop updateAliases(Crop crop, List<String> otherNames) {

		crop.setOtherNames(otherNames.stream().distinct().map(otherName -> StringUtils.trim(otherName)).filter(otherName -> StringUtils.isNotBlank(otherName)).sorted().collect(Collectors.toList()));

		return cropRepository.save(crop);
	}

	@Override
	@Cacheable(value = CACHE_CROPS, key = "'listCrops'")
	public List<Crop> listCrops() {
		List<Crop> crops = cropRepository.findAll();
		// Fetch otherNames list
		crops.stream().forEach(c -> {
			if (c.getOtherNames() != null)
				c.getOtherNames().size();
		});
		return crops;
	}


	@Override
	public List<CropDetails> listDetails(Locale locale) {
		StopWatch stopWatch = StopWatch.createStarted();
		List<TranslatedCrop> crops = translationSupport.getTranslated(cropRepository.findAll(), locale);
		LOG.trace("all crops after {}ms", stopWatch.getTime());
		// Fetch details
		List<Path> paths = crops.stream().map(c -> Paths.get("/crop", c.entity.getShortName(), "covers")).collect(Collectors.toList());
		List<ImageGallery> galleries = new ArrayList<>(crops.size());
		try {
			galleries.addAll(imageGalleryService.loadImageGalleries(paths));
		} catch (InvalidRepositoryPathException ex) {

		}

		List<CropDetails> details = crops.stream().map(crop -> {
			if (crop.entity.getOtherNames() != null)
				crop.entity.getOtherNames().size();

			ImageGallery imageGallery = galleries.stream()
				.filter(ig -> ig.getPath().equalsIgnoreCase("/crop/" + crop.entity.getShortName() + "/covers"))
				.findFirst()
				.orElse(null);

			return new CropDetails.Builder(crop).imageGallery(imageGallery).build();
		}).collect(Collectors.toList());
		LOG.trace("all image galleries after {}ms", stopWatch.getTime());

		return details;
	}

	@Override
	@Cacheable(value = CACHE_CROPS, key = "'findAll-'.concat(#locale)")
	public List<Crop> list(final Locale locale) {
		final List<Crop> crops = cropRepository.findAll();
		// Fetch otherNames list
		crops.stream().forEach(c -> {
			if (c.getOtherNames() != null)
				c.getOtherNames().size();
		});
		return crops;
	}

	@Override
	public List<Crop> getCrops(List<String> cropNames) {
		return cropRepository.findByShortNameIn(cropNames);
	}

	/**
	 * Register a crop with Genesys
	 */
	@Override
	@PreAuthorize("hasRole('ADMINISTRATOR')")
	@Transactional(readOnly = false)
	@CacheEvict(allEntries = true, value = CACHE_CROPS)
	public Crop addCrop(String shortName, String name, String description) {
		LOG.info("Adding crop {}", shortName);
		Crop crop = new Crop();

		crop.setShortName(shortName);
		crop.setName(name);
		crop.setDescription(StringUtils.defaultIfBlank(htmlSanitizer.sanitize(description), null));
		crop.setOriginalLanguageTag("en");
		crop = cropRepository.save(crop);
		LOG.info("Registered crop: {}", crop);

		return crop;
	}

	/**
	 * Update a crop record in Genesys
	 */
	@Override
	@PreAuthorize("hasRole('ADMINISTRATOR')")
	@Transactional(readOnly = false)
	@CacheEvict(allEntries = true, value = CACHE_CROPS)
	public Crop updateCrop(Crop crop, String name, String description) {
		LOG.info("Updating crop {}", crop);

		if (crop != null) {

			name = StringUtils.defaultIfBlank(name, null);
			if (name != null) {
				crop.setName(name);
			}

			description = StringUtils.defaultIfBlank(htmlSanitizer.sanitize(description), null);
			if (description != null) {
				crop.setDescription(description);
			}

			cropRepository.save(crop);

			LOG.info("Updated crop: {}", crop);
		}
		return crop;
	}

	@Override
	public void unlinkAccessionsForCrop(Crop crop) {
		final BooleanBuilder predicate = new BooleanBuilder();
		// accessions with selected crop
		predicate.and(QAccession.accession.crop().eq(crop));

		final BooleanBuilder cropNames = new BooleanBuilder();
		cropNames.or(QAccession.accession.cropName.equalsIgnoreCase(crop.getShortName()));
		cropNames.or(QAccession.accession.cropName.likeIgnoreCase(crop.getShortName()));
		crop.getOtherNames().forEach(otherName -> {
			cropNames.or(QAccession.accession.cropName.equalsIgnoreCase(otherName));
			cropNames.or(QAccession.accession.cropName.likeIgnoreCase(otherName));
		});
		// accession#cropName not one of valid names
		predicate.andNot(cropNames);

		accessionProcessor.apply(predicate, (accessions) -> {
			accessions.forEach(accession -> {
				Crop newCrop = getCrop(accession.getCropName());
				LOG.trace("{} from {}: {} -> {}", accession.getUuid(), accession.getInstCode(), accession.getCropName(), newCrop == null ? "null" : newCrop.getShortName());

				if (!Objects.equals(accession.getCrop(), newCrop)) {
					// assign crop, bypass auditing
					LOG.debug("Unlinking crop for {} from {}: {} = {}", accession.getUuid(), accession.getInstCode(), accession.getCropName(), newCrop == null ? "null" : newCrop.getShortName());
					accessionRepository.updateCrop(accession, newCrop);
				}
			});
		});
	}


	@Override
	public void linkAccessionsWithCrop(Crop crop) {
		final BooleanBuilder predicate = new BooleanBuilder();
		// accepted crop names
		predicate.or(QAccession.accession.cropName.equalsIgnoreCase(crop.getShortName()));
		predicate.or(QAccession.accession.cropName.likeIgnoreCase(crop.getShortName()));
		crop.getOtherNames().forEach(otherName -> {
			predicate.or(QAccession.accession.cropName.equalsIgnoreCase(otherName));
			predicate.or(QAccession.accession.cropName.likeIgnoreCase(otherName));
		});

//		// but not assigned to this crop
//		predicate.andAnyOf(QAccession.accession.crop.isNull(), QAccession.accession.crop.ne(crop));

		// Only if it does not have a Crop, because maybe it was assigned manually with `cropCode` upsert!
		predicate.and(QAccession.accession.crop().isNull());

		accessionProcessor.apply(predicate, (accessions) -> {
			accessions.forEach(accession -> {
				Crop newCrop = getCrop(accession.getCropName());
				LOG.trace("{} from {}: {} -> {}", accession.getUuid(), accession.getInstCode(), accession.getCropName(), newCrop == null ? "null" : newCrop.getShortName());

				if (!Objects.equals(newCrop, accession.getCrop())) {
					// assign crop, bypass auditing
					LOG.debug("Updating crop for {} from {}: {} = {}", accession.getUuid(), accession.getInstCode(), accession.getCropName(), newCrop == null ? "null" : newCrop.getShortName());
					accessionRepository.updateCrop(accession, newCrop);
				}
			});
		});
	}

	private Map<String, ElasticsearchService.TermResult> getOverviewData(AccessionFilter byCropFilter) {
		String[] terms = new String[] { "taxonomy.genus", "taxonomy.genusSpecies", "institute.code",
			"institute.country.code3", "mlsStatus", "available" };

		if (elasticsearchService == null)
			return Map.of();

		try {
			return elasticsearchService.termStatisticsAuto(Accession.class, byCropFilter, 10, terms);
		} catch (SearchException e) {
			LOG.error("Error occurred during search", e);
			return null;
		}
	}


	private void copyValues(Crop target, Crop source) {

		target.setDescription(source.getDescription());
		target.setName(source.getName());

		if (source.getOtherNames() != null) {
			target.setOtherNames(source.getOtherNames().stream().distinct().map(otherName -> StringUtils.trim(otherName)).filter(otherName -> StringUtils.isNotBlank(otherName)).sorted().collect(Collectors.toList()));
		}
		
		if (source.getOriginalLanguageTag() != null) {
			target.setOriginalLanguageTag(source.getOriginalLanguageTag());
		}
	}

	@Transactional
	public Crop update(final Crop crop) {
		final Crop toUpdate = getCrop(crop.getShortName());
		return update(crop, toUpdate);
	}

	@Override
	@Transactional
	public Crop update(Crop updated, Crop target) {
		return updateFast(updated, target);
	}

	@Override
	@PreAuthorize("hasRole('ADMINISTRATOR')")
	@Transactional
	@CacheEvict(allEntries = true, value = CACHE_CROPS)
	public Crop updateFast(Crop updated, Crop target) {
		copyValues(target, updated);
		return cropRepository.save(target);
	}

	@Transactional
	public Crop create(final Crop crop) {
		return createFast(crop);
	}

	@Override
	@PreAuthorize("hasRole('ADMINISTRATOR')")
	@Transactional
	@CacheEvict(allEntries = true, value = CACHE_CROPS)
	public Crop createFast(Crop source) {
		return super.createFast(source);
	}

	@Override
	public CropLang machineTranslate(Crop original, String targetLanguage) throws TranslatorService.TranslatorException {
		var mt = new CropLang();
		mt.setMachineTranslated(true);
		mt.setLanguageTag(targetLanguage);
		mt.setEntity(original);

		if (translatorService == null) return mt;

		var builder = TranslatorService.TranslationStructuredRequest.builder()
			.targetLang(targetLanguage);

		if (! Objects.equals(Locale.ENGLISH.getLanguage(), targetLanguage) && ! Objects.equals(Locale.ENGLISH.getLanguage(), original.getOriginalLanguageTag())) {
			// Translations to other languages use the English version (either original or translated)
			var enTranslation = translationSupport.getLang(original, Locale.ENGLISH.getLanguage());
			if (enTranslation == null) {
				throw new InvalidApiUsageException("English text is not available.");
			}

			builder
				.sourceLang(enTranslation.getLanguageTag())
				// .context(enTranslation.getDescription())
				.texts(Map.of(
					"name", new FormattedText(TextFormat.markdown, enTranslation.getTitle()),
					"description", new FormattedText(TextFormat.markdown, enTranslation.getDescription())
				));

		} else {
			// Translations to English use the original texts
			builder
				.sourceLang(original.getOriginalLanguageTag())
				// .context(original.getDescription())
				.texts(Map.of(
					"name", new FormattedText(TextFormat.text, original.getName()),
					"description", new FormattedText(TextFormat.html, original.getDescription())
				));
		}

		builder.texts(Map.of(
			"name", new FormattedText(TextFormat.text, original.getName()),
			"description", new FormattedText(TextFormat.html, original.getDescription())
		));

		var translations = translatorService.translate(builder.build());

		if (StringUtils.isNotBlank(original.getName())) {
			mt.setTitle(translations.getTexts().get("name"));
		}
		if (StringUtils.isNotBlank(original.getDescription())) {
			mt.setDescription(translations.getTexts().get("description"));
		}
		return mt;
	}
}