DescriptorServiceImpl.java

/*
 * Copyright 2018 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 static org.genesys.server.model.traits.QDescriptor.descriptor;

import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.Map.Entry;
import java.util.stream.Collectors;

import javax.persistence.EntityManager;

import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.genesys.blocks.security.model.AclSid;
import org.genesys.blocks.security.serialization.Permissions;
import org.genesys.blocks.security.service.CustomAclService;
import org.genesys.filerepository.FolderNotEmptyException;
import org.genesys.filerepository.InvalidRepositoryFileDataException;
import org.genesys.filerepository.InvalidRepositoryPathException;
import org.genesys.filerepository.NoSuchRepositoryFileException;
import org.genesys.filerepository.model.RepositoryFolder;
import org.genesys.filerepository.model.RepositoryImage;
import org.genesys.filerepository.service.RepositoryService;
import org.genesys.server.component.security.AsAdminInvoker;
import org.genesys.server.component.security.SecurityUtils;
import org.genesys.server.exception.InvalidApiUsageException;
import org.genesys.server.exception.NotFoundElement;
import org.genesys.server.exception.SearchException;
import org.genesys.server.model.Partner;
import org.genesys.server.model.PublishState;
import org.genesys.server.model.UserRole;
import org.genesys.server.model.dataset.Dataset;
import org.genesys.server.model.filters.DescriptorFilter;
import org.genesys.server.model.filters.FilterHelpers;
import org.genesys.server.model.traits.Descriptor;
import org.genesys.server.model.traits.DescriptorLang;
import org.genesys.server.model.traits.DescriptorList;
import org.genesys.server.model.traits.QDescriptor;
import org.genesys.server.model.vocab.QVocabularyTerm;
import org.genesys.server.model.vocab.VocabularyTerm;
import org.genesys.server.model.vocab.VocabularyTermLang;
import org.genesys.server.persistence.DescriptorLangRepository;
import org.genesys.server.persistence.PartnerRepository;
import org.genesys.server.persistence.traits.DescriptorRepository;
import org.genesys.server.persistence.vocab.VocabularyTermRepository;
import org.genesys.server.service.DescriptorService;
import org.genesys.server.service.DescriptorTranslationService;
import org.genesys.server.service.DescriptorTranslationService.TranslatedDescriptor;
import org.genesys.server.service.DownloadService;
import org.genesys.server.service.ElasticsearchService;
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.TranslatorService.TranslatorException;
import org.genesys.server.service.VersionManager;
import org.genesys.server.service.VocabularyTermTranslationService;
import org.genesys.util.HibernateUtil;
import org.genesys.util.JPAUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.dao.ConcurrencyFailureException;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.security.access.prepost.PostAuthorize;
import org.springframework.security.access.prepost.PostFilter;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.acls.domain.BasePermission;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.multipart.MultipartFile;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.collect.Lists;
import com.querydsl.core.BooleanBuilder;
import com.querydsl.core.types.Predicate;
import com.querydsl.core.types.dsl.BooleanExpression;

/**
 * The Class DescriptorServiceImpl.
 */
@Slf4j
@Service("catalogDescriptorService")
@Transactional(readOnly = true)
@Validated
public class DescriptorServiceImpl extends FilteredTranslatedCRUDServiceImpl<
	Descriptor, DescriptorLang, DescriptorTranslationService.TranslatedDescriptor, DescriptorFilter, DescriptorRepository>
	implements DescriptorService {

	private static final Predicate[] EMPTY_PREDICATE_ARRAY = new Predicate[] {};

	/** The descriptor repository. */
	@Autowired
	private DescriptorRepository descriptorRepository;

	@Autowired
	private VocabularyTermRepository termRepository;

	@Autowired
	private DownloadService downloadService;

	/** The securityUtils. */
	@Autowired
	private SecurityUtils securityUtils;

	@Autowired
	private EntityManager entityManager;

	@Autowired
	private VersionManager versionManager;

	@Autowired(required = false)
	private ElasticsearchService elasticsearchService;

	/** The file repo service. */
	@Autowired
	private RepositoryService repositoryService;

	@Autowired
	private CustomAclService aclService;

	/** Execute code as admin */
	@Autowired
	protected AsAdminInvoker asAdminInvoker;

	@Autowired
	private PartnerRepository partnerRepository;

	@Autowired(required = false)
	private TranslatorService translatorService;


	@Component(value = "DescriptorTranslationSupport")
	protected static class DescriptorTranslationSupport
		extends BaseTranslationSupport<
		Descriptor, DescriptorLang, DescriptorTranslationService.TranslatedDescriptor, DescriptorFilter, DescriptorLangRepository>
		implements DescriptorTranslationService {

		@Autowired
		@Lazy
		private VocabularyTermTranslationService vocabularyTermTranslationService;

		@Override
		@PreAuthorize("hasRole('ADMINISTRATOR') or hasPermission(#source.entity, 'write')")
		public DescriptorLang create(DescriptorLang source) {
			return super.create(source);
		}

		@Override
		@PreAuthorize("hasRole('ADMINISTRATOR') or hasPermission(#target.entity, 'write')")
		public DescriptorLang update(DescriptorLang updated, DescriptorLang target) {
			return super.update(updated, target);
		}

		@Override
		@PreAuthorize("hasRole('ADMINISTRATOR') or hasPermission(#source.entity, 'write')")
		public DescriptorLang remove(DescriptorLang source) {
			return super.remove(source);
		}

		@Override
		public TranslatedDescriptor getTranslated(Descriptor input, Locale locale) {
			var td = super.getTranslated(input, locale);
			td.setTerms(vocabularyTermTranslationService.getTranslated(td.entity.getTerms(), locale));
			return td;
		}

		@Override
		public List<TranslatedDescriptor> getTranslated(List<Descriptor> input, Locale locale) {
			var tds = super.getTranslated(input, locale);
			tds.forEach(td -> {
				td.setTerms(vocabularyTermTranslationService.getTranslated(td.entity.getTerms(), locale));
			});
			return tds;
		}
	}

	/**
	 * {@inheritDoc}
	 */
	@Transactional
	@Override
	@PreAuthorize("hasRole('ADMINISTRATOR') or hasPermission(#input.owner, 'write')")
	public Descriptor create(final Descriptor input) {
		return lazyLoad(createFast(input));
	}

	@Transactional
	@Override
	@PreAuthorize("hasRole('ADMINISTRATOR') or hasPermission(#input.owner, 'write')")
	public Descriptor createFast(Descriptor input) {
		log.info("Creating descriptor: {} - {}", input.getTitle(), input.getDataType());
		input.setOwner(partnerRepository.findById(input.getOwner().getId()).orElseThrow());
		updateTerms(input);

		final Descriptor descriptor = new Descriptor();
		descriptor.apply(input);
		descriptor.setUuid(input.getUuid());
		descriptor.setVersionTag(input.getVersionTag());

		// can not be published when creating
		descriptor.setState(PublishState.DRAFT);

		final Descriptor saved = descriptorRepository.save(descriptor);

		// Grant all permissions to the Partner's SID
		final AclSid sid = aclService.ensureAuthoritySid(saved.getOwner().getAuthorityName());
		aclService.setPermissions(saved, sid, new Permissions().grantAll());

		return saved;
	}

	private Descriptor getDescriptor(final Descriptor input) {
		Descriptor descriptor = null;

		if (input.getUuid() != null) {
			descriptor = descriptorRepository.findByUuid(input.getUuid());
		} else {
			descriptor = descriptorRepository.findById(input.getId()).orElse(null);
		}

		if (descriptor == null) {
			throw new NotFoundElement("Record not found by uuid=" + input.getUuid() + " or id=" + input.getId());
		}

		if (input.getVersion() != null && !descriptor.getVersion().equals(input.getVersion())) {
			throw new ConcurrencyFailureException("Object version changed to " + descriptor.getVersion() + ", you provided " + input.getVersion());
		}

		return descriptor;
	}

	private Descriptor getUnpublishedDescriptor(Descriptor descriptor) {
		descriptor = getDescriptor(descriptor);
		if (descriptor.isPublished()) {
			throw new InvalidApiUsageException("Cannot modify a published Descriptor.");
		}
		return descriptor;
	}

	/**
	 * {@inheritDoc}
	 */
	@Override
	@PostFilter("hasRole('ADMINISTRATOR') or filterObject.published or hasPermission(filterObject, 'READ')")
	public List<Descriptor> searchMatchingDescriptor(final Descriptor input) {
		try {
			input.trimStringsToNull();
			log.debug("searchMatching: {}", new ObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsString(input));
		} catch (IOException e) {
		}

		List<Predicate> searches = Lists.newArrayList();
		// version + title
		searches.add(descriptor.versionTag.eq(input.getVersionTag()).and(descriptor.title.eq(input.getTitle())));
		if (StringUtils.isNotBlank(input.getDescription())) {
			// description
			searches.add(descriptor.description.eq(input.getDescription()));
		}
		if (StringUtils.isNotBlank(input.getDescription())) {
			// title + dataType
			searches.add(descriptor.title.eq(input.getTitle()).and(descriptor.dataType.eq(input.getDataType())));
		}
		if (input.getCategory() != null) {
			// title + category
			searches.add(descriptor.title.eq(input.getTitle()).and(descriptor.category.eq(input.getCategory())));
		}
		if (StringUtils.isNotBlank(input.getUom())) {
			// uom
			searches.add(descriptor.dataType.eq(input.getDataType()).and(descriptor.uom.eq(input.getUom())));
		}

		final Predicate predicate = StringUtils.isBlank(input.getCrop()) ? descriptor.crop.isNull().andAnyOf(searches.toArray(EMPTY_PREDICATE_ARRAY)) : descriptor.crop.eq(input.getCrop()).andAnyOf(searches.toArray(EMPTY_PREDICATE_ARRAY));

		List<Descriptor> matches = new ArrayList<>();
		descriptorRepository.findAll(predicate).forEach(match -> matches.add(lazyLoad(match)));
		return matches;
	}

	@Override
	@Transactional
	@PreAuthorize("hasRole('ADMINISTRATOR') or hasPermission(#descriptor, 'write')")
	public Descriptor updateImage(Descriptor descriptor, final MultipartFile file, final RepositoryImage imageMetadata) throws IOException, InvalidRepositoryPathException,
		InvalidRepositoryFileDataException, NoSuchRepositoryFileException {

		if (securityUtils.hasRole(UserRole.ADMINISTRATOR)) {
			descriptor = getDescriptor(descriptor); // Editorial changes work regardless of state
		} else {
			descriptor = getUnpublishedDescriptor(descriptor);
		}

		if (!file.getContentType().startsWith("image/")) {
			throw new InvalidApiUsageException("Invalid image type: " + file.getContentType() + " is not permitted.");
		}

		RepositoryImage repositoryImage = descriptor.getImage();

		if (repositoryImage != null) {
			descriptor = removeImage(descriptor);
		}

		String originalFilename = file.getOriginalFilename();
		String ext = "";
		if (originalFilename != null) {
			int dotIndex = originalFilename.lastIndexOf(".");
			ext = dotIndex > 0 ? originalFilename.substring(dotIndex) : "";
		}

		// Make descriptor folder
		var descriptorFolder = ensureDescriptorFolder(descriptor);

		repositoryImage = repositoryService.addFile(descriptorFolder.getFolderPath(), descriptor.getUuid().toString() + ext, file.getContentType(), file.getInputStream(), imageMetadata);
		descriptor.setImage(repositoryImage);

		return lazyLoad(descriptorRepository.save(descriptor));
	}


	private RepositoryFolder ensureDescriptorFolder(final Descriptor descriptor) {
		try {
			final Path descriptorPath = getRepositoryImageFolder(descriptor);
			final Partner partner = descriptor.getOwner();

			// Ensure folder ownership
			return asAdminInvoker.invoke(() -> {
				// Ensure target folder exists for the Descriptor
				return repositoryService.ensureFolder(descriptorPath, partner);
			});

		} catch (Exception e) {
			log.warn("Could not create folder properties: {}", e.getMessage());
			throw new InvalidApiUsageException("Could not create folder", e);
		}
	}


	@Override
	@Transactional(rollbackFor = Throwable.class)
	@PreAuthorize("hasRole('ADMINISTRATOR') or hasPermission(#descriptor, 'write')")
	public Descriptor removeImage(Descriptor descriptor) throws InvalidRepositoryPathException, IOException {

		if (securityUtils.hasRole(UserRole.ADMINISTRATOR)) {
			descriptor = getDescriptor(descriptor); // Editorial changes work regardless of state
		} else {
			descriptor = getUnpublishedDescriptor(descriptor);
		}

		deleteDescriptorImage(descriptor);

		return lazyLoad(descriptorRepository.save(descriptor));
	}

	private void deleteDescriptorImage(Descriptor descriptor) throws IOException {
		RepositoryImage descriptorImage = descriptor.getImage();
		descriptor.setImage(null);

		if (descriptorImage != null) {
			descriptorImage = HibernateUtil.unproxy(descriptorImage);
			Path folderPath = descriptorImage.getFolder().getFolderPath();
			try {
				repositoryService.removeFile(descriptorImage);
			} catch (NoSuchRepositoryFileException e) {
				// That's fine
				log.warn("Could not remove descriptor image: {}", e.getMessage());
			}
			try {
				repositoryService.deleteFolder(folderPath);
			} catch (Throwable e) {
				// Weird
				log.warn("Could not remove descriptor image folder: {}", e.getMessage());
			}
		}
	}

	private Path getRepositoryImageFolder(final Descriptor descriptor) {
		assert (descriptor != null);
		assert (descriptor.getUuid() != null);
		String uuid = descriptor.getUuid().toString();

		return Paths.get("/descriptor", uuid.substring(0, 3), uuid).toAbsolutePath();
	}

	/**
	 * Persist or update terms in descriptor itself. It updates descriptor's own
	 * terms List
	 *
	 * @param descriptor the descriptor
	 */
	protected void updateTerms(final Descriptor descriptor) {
		final List<VocabularyTerm> terms = descriptor.getTerms();
		if (terms != null && !terms.isEmpty()) {
			terms.stream().collect(Collectors.groupingBy(VocabularyTerm::getCode))
				.entrySet().stream().filter(e -> e.getValue().size() > 1).findFirst()
				.ifPresent(e -> {
					throw new InvalidApiUsageException("Terms with duplicate codes are not allowed.");
				}
			);

			if (descriptor.getId() != null) {
				var remainingTermIds = terms.stream().map(VocabularyTerm::getId).filter(id -> id != null).collect(Collectors.toSet());
				var existedForRemove = termRepository.findAll(
					QVocabularyTerm.vocabularyTerm.descriptor().id.eq(descriptor.getId())
						.and(QVocabularyTerm.vocabularyTerm.id.notIn(remainingTermIds))
				);
				if (existedForRemove.iterator().hasNext()) {
					termRepository.deleteAll(existedForRemove);
				}
			}
			terms.forEach(term -> term.setOriginalLanguageTag(descriptor.getOriginalLanguageTag())); // Force set same language
			final List<VocabularyTerm> r = termRepository.saveAllAndFlush(terms);
			terms.clear();
			log.debug("Adding terms {}", r);
			terms.addAll(r);
		}
	}

	/**
	 * {@inheritDoc}
	 */
	@Transactional
	@Override
	public Descriptor update(final Descriptor descriptor) {
		final Descriptor loaded = getUnpublishedDescriptor(descriptor);
		return update(descriptor, loaded);
	}

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

	@Override
	@Transactional
	@PreAuthorize("hasRole('ADMINISTRATOR') or hasPermission(#target, 'WRITE')")
	public Descriptor updateFast(Descriptor updated, Descriptor target) {
		log.info("Updating descriptor uuid={} id={}", updated.getUuid(), updated.getId());
		final Partner owner = target.getOwner();

		if (updated.getOwner() != null && !target.getOwner().getUuid().equals(updated.getOwner().getUuid())) {
			throw new InvalidApiUsageException("Descriptor owner can't be changed");
		}
		updateTerms(updated);
		target.apply(updated);
		// Keep owner
		target.setOwner(owner);
		target.setState(PublishState.DRAFT);
		return descriptorRepository.save(target);
	}

	/**
	 * {@inheritDoc}
	 */
	@Transactional
	@Override
	@PreAuthorize("hasRole('ADMINISTRATOR')")
	public Descriptor forceUpdateDescriptor(final Descriptor descriptor) {
		final Descriptor loaded = getDescriptor(descriptor); // Editorial changes work regardless of state
		log.info("Updating descriptor uuid={} id={}", descriptor.getUuid(), descriptor.getId());
		final Partner owner = loaded.getOwner();

		if (descriptor.getOwner() != null && !loaded.getOwner().equals(descriptor.getOwner())) {
			throw new InvalidApiUsageException("Descriptor owner can't be changed");
		}
		updateTerms(descriptor);
		loaded.apply(descriptor);
		// Keep owner
		loaded.setOwner(owner);
//		loaded.setState(PublishState.DRAFT); // Editoral changes do not modify state
		return lazyLoad(descriptorRepository.save(loaded));
	}

	/**
	 * {@inheritDoc}
	 */
	@Override
	@PostAuthorize("hasRole('ADMINISTRATOR') || returnObject==null || returnObject.isPublished() || hasPermission(returnObject, 'write')")
	public Descriptor getDescriptor(final UUID uuid) {
		return descriptorRepository.findByUuid(uuid);
	}

	/**
	 * {@inheritDoc}
	 */
	@Override
	@PostAuthorize("hasRole('ADMINISTRATOR') || returnObject==null || returnObject.isPublished() || hasPermission(returnObject, 'write')")
	public Descriptor loadDescriptor(final UUID uuid) {
		return lazyLoad(descriptorRepository.findByUuid(uuid));
	}

	@Override
	@PostAuthorize("hasRole('ADMINISTRATOR') || returnObject==null || returnObject.entity.isPublished() || hasPermission(returnObject.entity, 'write')")
	public DescriptorTranslationService.TranslatedDescriptor loadTranslatedDescriptor(UUID uuid) throws NotFoundElement {
		return translate(descriptorRepository.findByUuid(uuid));
	}

	/**
	 * {@inheritDoc}
	 */
	@Override
	@PostAuthorize("hasRole('ADMINISTRATOR') || returnObject==null || returnObject.isPublished() || hasPermission(returnObject, 'read')")
	public Descriptor loadDescriptor(final UUID uuid, final int version) {
		return lazyLoad(descriptorRepository.findByUuidAndVersion(uuid, version));
	}

	/**
	 * {@inheritDoc}
	 */
	@Override
	@PreAuthorize("isAuthenticated()")
	public Page<Descriptor> listDescriptorsForCurrentUser(final DescriptorFilter filter, final Pageable page) throws IOException, SearchException {
		Pageable markdownSortPageRequest = JPAUtils.toMarkdownSort(page, "title");
		Page<Descriptor> res;

		if (securityUtils.hasRole(UserRole.ADMINISTRATOR)) {
			if (filter.isFulltextQuery()) {
				res = elasticsearchService.findAll(Descriptor.class, filter, markdownSortPageRequest);
			} else {
				res = descriptorRepository.findAll(filter.buildPredicate(), markdownSortPageRequest);
			}
			return new PageImpl<>(res.getContent(), page, res.getTotalElements());
		} else {
			final HashSet<Long> partners = new HashSet<>(securityUtils.listObjectIdentityIdsForCurrentUser(Partner.class, BasePermission.WRITE));
			DescriptorFilter partnerFilter = filter.copy(DescriptorFilter.class);
			partnerFilter.owner().id(partners);

			if (filter.isFulltextQuery()) {
				res = elasticsearchService.findAll(Descriptor.class, partnerFilter, markdownSortPageRequest);
			} else {
				res = descriptorRepository.findAll(partnerFilter.buildPredicate(), markdownSortPageRequest);
			}

		}
		return new PageImpl<>(res.getContent(), page, res.getTotalElements());
	}

	@Override
	@PreAuthorize("isAuthenticated()")
	public Page<Descriptor> listAccessibleDescriptors(DescriptorFilter descriptorFilter, Pageable page) throws IOException, SearchException {
		Pageable markdownSortPageRequest = JPAUtils.toMarkdownSort(page, "title");
		Page<Descriptor> res;

		if (securityUtils.hasRole(UserRole.ADMINISTRATOR)) {
			res = listDescriptorsForCurrentUser(descriptorFilter, markdownSortPageRequest);
		} else {
			final HashSet<Long> partners = new HashSet<>(securityUtils.listObjectIdentityIdsForCurrentUser(Partner.class, BasePermission.WRITE));
			BooleanExpression and = QDescriptor.descriptor.state.in(PublishState.PUBLISHED).or(descriptor.owner().id.in(partners)).and(descriptorFilter.buildPredicate());

			if (descriptorFilter.isFulltextQuery()) {
				and = and.andAnyOf(FilterHelpers.containsAll(descriptorFilter.get_text(), QDescriptor.descriptor.title, QDescriptor.descriptor.columnName, QDescriptor.descriptor.description, QDescriptor.descriptor.crop));
			}
			res = descriptorRepository.findAll(and, markdownSortPageRequest);
		}

		return new PageImpl<>(res.getContent(), page, res.getTotalElements());
	}

	/**
	 * {@inheritDoc}
	 */
	@Override
	public Page<Descriptor> listDescriptors(final DescriptorFilter descriptorFilter, final Pageable page) throws SearchException {
		Pageable markdownSortPageRequest = JPAUtils.toMarkdownSort(page, "title");
		Page<Descriptor> res;
		if (descriptorFilter.isFulltextQuery()) {
			res = elasticsearchService.findAll(Descriptor.class, descriptorFilter, page);
		} else {
			res = descriptorRepository.findAll(new BooleanBuilder().and(descriptorFilter.buildPredicate()).and(QDescriptor.descriptor.state.in(PublishState.PUBLISHED)),
				markdownSortPageRequest);
		}
		return new PageImpl<>(res.getContent(), page, res.getTotalElements());
	}

	@Override
	public Page<TranslatedDescriptor> listDescriptorsDetails(DescriptorFilter filter, Pageable page) throws SearchException {
		var descriptors = listDescriptors(filter, page);
		descriptors.getContent().forEach(d -> d.getTerms().size());
		return new PageImpl<>(translationSupport.getTranslated(descriptors.getContent()), page, descriptors.getTotalElements());
	}

	/**
	 * {@inheritDoc}
	 *
	 * @throws IOException
	 * @throws InvalidRepositoryPathException
	 * @throws FolderNotEmptyException
	 * @throws NoSuchRepositoryFileException
	 */
	@Transactional(rollbackFor = Throwable.class)
	@Override
	@PreAuthorize("hasRole('ADMINISTRATOR') or hasPermission(#descriptor, 'delete')")
	public Descriptor removeDescriptor(final Descriptor descriptor) throws IOException {
		final Descriptor loadedDescriptor = getUnpublishedDescriptor(descriptor);

		deleteDescriptorImage(loadedDescriptor);

		descriptorRepository.delete(loadedDescriptor);
		return loadedDescriptor;
	}

	@Transactional(rollbackFor = Throwable.class)
	@Override
	@PreAuthorize("hasRole('ADMINISTRATOR') or hasPermission(#entity, 'delete')")
	public Descriptor remove(Descriptor entity) {
		try {
			return removeDescriptor(entity);
		} catch (Exception e) {
			throw new InvalidApiUsageException(e);
		}
	}

	/**
	 * Lazy load for objects in descriptor.
	 *
	 * @param descriptor descriptor
	 * @return descriptor with loaded inner objects
	 */
	private Descriptor lazyLoad(final Descriptor descriptor) {
		if (descriptor == null) {
			throw new NotFoundElement("No such descriptor");
		}

		descriptor.lazyLoad();

		return descriptor;
	}

	@Override
	@Transactional
	@PreAuthorize("hasRole('ADMINISTRATOR')")
	public List<Descriptor> upsertDescriptors(final List<Descriptor> sources) {
		final List<Descriptor> updates = new ArrayList<>();

		for (final Descriptor source : sources) {
			updates.add(upsertDescriptor(source));
		}

		return updates;
	}

	@Override
	@Transactional
	@PreAuthorize("hasRole('ADMINISTRATOR')")
	public Descriptor upsertDescriptor(final Descriptor source) {
		Descriptor target;
		if (source.getVersion() != null) {
			target = getUnpublishedDescriptor(source);
		} else {
			target = new Descriptor();
			target.setUuid(source.getUuid());
		}
		updateTerms(source);
		target.apply(source);
		return descriptorRepository.save(target);
	}

	@Override
	@Transactional
	@PreAuthorize("hasRole('ADMINISTRATOR')")
	public Descriptor approveDescriptor(final Descriptor descriptor) {
		final Descriptor loaded = getUnpublishedDescriptor(descriptor);

		if (loaded.getState() == PublishState.DRAFT) {
			throw new InvalidApiUsageException("Descriptor should be sent for review before publication");
		}

		loaded.setState(PublishState.PUBLISHED);

		if (loaded.getImage() != null) {
			// Relax permissions on descriptor image: allow USERS and ANONYMOUS to read the image
			aclService.makePubliclyReadable(HibernateUtil.unproxy(loaded.getImage()), true);
		}

		return lazyLoad(descriptorRepository.saveAndFlush(loaded));
	}

	@Override
	@Transactional
	@PreAuthorize("hasRole('ADMINISTRATOR') or hasPermission(#descriptor, 'administration')")
	public Descriptor reviewDescriptor(final Descriptor descriptor) {
		final Descriptor loaded = getUnpublishedDescriptor(descriptor);

		if (loaded.getState() == PublishState.REVIEWING) {
			throw new InvalidApiUsageException("The Descriptor is already under approval");
		}

		loaded.setState(PublishState.REVIEWING);

		return lazyLoad(descriptorRepository.save(loaded));
	}

	/**
	 * {@inheritDoc}
	 */
	@Override
	@Transactional(isolation = Isolation.READ_UNCOMMITTED, noRollbackFor = InvalidApiUsageException.class)
	@PreAuthorize("hasRole('ADMINISTRATOR') or hasPermission(#descriptor, 'administration')")
	public Descriptor rejectDescriptor(final Descriptor descriptor) {
		final Descriptor loaded = getDescriptor(descriptor);

		if (loaded.isPublished() && !securityUtils.hasRole(UserRole.ADMINISTRATOR)) {
			if (loaded.getLastModifiedDate() != null && Instant.now().minus(24, ChronoUnit.HOURS).isAfter(loaded.getLastModifiedDate())) {
				throw new InvalidApiUsageException("Cannot be un-published. More than 24 hours have passed since the publication.");
			}
		}

		{
			for (final Dataset referencedDataset : loaded.getDatasets()) {
				if (referencedDataset.getState() == PublishState.PUBLISHED) {
					throw new InvalidApiUsageException("Cannot be un-published. The descriptor is referenced by a published dataset.");
				}
			}
			for (final DescriptorList referencedDescriptorList : loaded.getDescriptorLists()) {
				if (referencedDescriptorList.getState() == PublishState.PUBLISHED) {
					throw new InvalidApiUsageException("Cannot be un-published. The descriptor is referenced by a published descriptor list.");
				}
			}
		}

		loaded.setState(PublishState.DRAFT);

		if (loaded.getImage() != null) {
			// Tighten permissions on descriptor image
			aclService.makePubliclyReadable(HibernateUtil.unproxy(loaded.getImage()), false);
		}

		return lazyLoad(descriptorRepository.save(loaded));
	}

	/**
	 * {@inheritDoc}
	 */
	@Override
	@PreAuthorize("#descriptor.isPublished() || hasRole('ADMINISTRATOR') || hasPermission(#descriptor, 'read')")
	@PostFilter("hasRole('ADMINISTRATOR') || filterObject==null || filterObject.isPublished() || hasPermission(filterObject, 'write')")
	public List<DescriptorList> getDescriptorLists(final Descriptor descriptor) {
		final List<DescriptorList> list = descriptorRepository.listDescriptorLists(descriptor);
		list.forEach(d -> entityManager.detach(d));
		return list;
	}

	/**
	 * {@inheritDoc}
	 */
	@Override
	@PreAuthorize("hasRole('ADMINISTRATOR') || #descriptor.isPublished() || hasPermission(#descriptor, 'read')")
	@PostFilter("hasRole('ADMINISTRATOR') || filterObject==null || filterObject.isPublished() || hasPermission(filterObject, 'write')")
	public List<Dataset> getDatasets(final Descriptor descriptor) {
		final List<Dataset> list = descriptorRepository.listDatasets(descriptor);
		list.forEach(d -> entityManager.detach(d));
		return list;
	}

	@Override
	@Transactional
	@PreAuthorize("#descriptor.isPublished() || hasRole('ADMINISTRATOR') || hasPermission(#descriptor, 'read')")
	public Descriptor nextVersion(final Descriptor descriptor, final boolean major) {
		final Descriptor source = getDescriptor(descriptor);
		log.info("Creating new version for descriptor uuid={} id={}", descriptor.getUuid(), descriptor.getId());
		final Descriptor copy = new Descriptor();
		copy.apply(source);
		copy.setUuid(null);
		copy.setState(PublishState.DRAFT);
		copy.setVersionTag(versionManager.next(descriptor.getVersionTag(), major));
		copy.setOwner(descriptor.getOwner());
		return lazyLoad(descriptorRepository.save(copy));
	}

	@Override
	public void exportDescriptors(DescriptorFilter filter, OutputStream outputStream) throws IOException {
		List<Descriptor> descriptors = (List<Descriptor>) descriptorRepository.findAll(filter.buildPredicate());

		downloadService.writeXlsxDescriptor(descriptors, outputStream);
	}

	@Override
	public long countDescriptors(DescriptorFilter filter) throws SearchException {
		if (filter.isFulltextQuery()) {
			return elasticsearchService.count(DescriptorList.class, filter);
		}
		return descriptorRepository.count(filter.buildPredicate());
	}

	@Override
	@PreAuthorize("hasRole('ADMINISTRATOR') || hasPermission(#original, 'write')")
	public DescriptorLang machineTranslate(Descriptor original, String targetLanguage) throws TranslatorService.TranslatorException {
		if (Objects.equals(original.getOriginalLanguageTag(), targetLanguage)) {
			throw new InvalidApiUsageException("Source and target language are the same");
		}

		var mt = new DescriptorLang();
		mt.setMachineTranslated(true);
		mt.setLanguageTag(targetLanguage);
		mt.setEntity(original);

		if (translatorService == null) return mt;

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

			// Translations to other languages use the English version (either original or translated)
		if (! Objects.equals(Locale.ENGLISH.getLanguage(), targetLanguage) && ! Objects.equals(Locale.ENGLISH.getLanguage(), original.getOriginalLanguageTag())) {
			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(
					"title", 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(
					"title", new FormattedText(TextFormat.markdown, original.getTitle()),
					"description", new FormattedText(TextFormat.markdown, original.getDescription())
				));
		}

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

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

	@Override
	@Transactional(propagation = Propagation.MANDATORY, readOnly = true)
	@PreAuthorize("hasRole('ADMINISTRATOR') || hasPermission(#descriptor, 'write')")
	public List<VocabularyTermLang> machineTranslateTerms(Descriptor descriptor, String targetLanguage) {

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

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

			builder
				.sourceLang(enTranslation.getTranslation().getLanguageTag())
				// .context(enTranslation.getDescription())
				.texts(enTranslation.getTerms().stream().map(term -> {
					return List.of(
						Map.entry("title" + term.getEntity().getId(), new FormattedText(TextFormat.markdown, term.getTranslation().getTitle())),
						Map.entry("description" + term.getEntity().getId(), new FormattedText(TextFormat.markdown, term.getTranslation().getDescription()))
					);
				}).flatMap(x -> x.stream()).filter(entry -> Objects.nonNull(entry.getValue().getText())).collect(Collectors.toMap(Entry::getKey, Entry::getValue)));

		} else {
			// Translations to English use the original texts
			builder
				.sourceLang(descriptor.getOriginalLanguageTag())
				// .context(original.getDescription())
				.texts(descriptor.getTerms().stream().map(term -> {
					return List.of(
						Map.entry("title" + term.getId(), new FormattedText(TextFormat.markdown, term.getTitle())),
						Map.entry("description" + term.getId(), new FormattedText(TextFormat.markdown, term.getDescription()))
					);
				}).flatMap(x -> x.stream()).filter(entry -> Objects.nonNull(entry.getValue().getText())).collect(Collectors.toMap(Entry::getKey, Entry::getValue)));
		}


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

			return descriptor.getTerms().stream().map(term -> {
				var mt = new VocabularyTermLang();
				mt.setEntity(term);
				mt.setMachineTranslated(true);
				mt.setLanguageTag(targetLanguage);
				if (StringUtils.isNotBlank(term.getTitle())) {
					mt.setTitle(translations.getTexts().get("title" + term.getId()));
				}
				if (StringUtils.isNotBlank(term.getDescription())) {
					mt.setDescription(translations.getTexts().get("description" + term.getId()));
				}
				return mt;
			}).filter(Objects::nonNull).collect(Collectors.toList());

		} catch (TranslatorException e) {
			return null;
		}
	}
}