ActivityPostServiceImpl.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.genesys.server.service.impl;

import com.querydsl.core.BooleanBuilder;

import org.apache.commons.lang3.StringUtils;
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.RepositoryFile;
import org.genesys.filerepository.model.RepositoryFolder;
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.model.UserRole;
import org.genesys.server.model.impl.ActivityPost;
import org.genesys.server.model.impl.ActivityPostLang;
import org.genesys.server.model.impl.QActivityPost;
import org.genesys.server.persistence.ActivityPostLangRepository;
import org.genesys.server.persistence.ActivityPostRepository;
import org.genesys.server.service.ActivityPostService;
import org.genesys.server.service.ActivityPostTranslationService;
import org.genesys.server.service.HtmlSanitizer;
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.ActivityPostTranslationService.TranslatedActivityPost;
import org.genesys.server.service.filter.ActivityPostFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
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 org.springframework.web.multipart.MultipartFile;

import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Instant;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;

@Service
@Transactional(readOnly = true)
@Validated
public class ActivityPostServiceImpl 
		extends FilteredTranslatedCRUDServiceImpl<ActivityPost, ActivityPostLang, ActivityPostTranslationService.TranslatedActivityPost, ActivityPostFilter, ActivityPostRepository>
		implements ActivityPostService {

	@Autowired
	private SecurityUtils securityUtils;

	@Autowired
	private HtmlSanitizer htmlSanitizer;

	@Autowired
	protected RepositoryService repositoryService;

	@Autowired
	private CustomAclService aclService;

	@Autowired
	protected AsAdminInvoker asAdminInvoker;
	
	@Autowired(required = false)
	private TranslatorService translatorService;

	@Component(value = "ActivityPostTranslationSupport")
	protected static class ActivityPostTranslationSupport
		extends BaseTranslationSupport<ActivityPost, ActivityPostLang, ActivityPostTranslationService.TranslatedActivityPost, ActivityPostFilter, ActivityPostLangRepository>
		implements ActivityPostTranslationService {

		@Override
		@Transactional
		@PreAuthorize("hasRole('ADMINISTRATOR') or hasRole('CONTENTMANAGER')")
		public ActivityPostLang create(ActivityPostLang source) {
			return super.create(source);
		}

		@Override
		@Transactional
		@PreAuthorize("hasRole('ADMINISTRATOR') or hasRole('CONTENTMANAGER')")
		public ActivityPostLang update(ActivityPostLang updated, ActivityPostLang target) {
			return super.update(updated, target);
		}

		@Override
		@Transactional
		@PreAuthorize("hasRole('ADMINISTRATOR') or hasRole('CONTENTMANAGER')")
		public ActivityPostLang remove(ActivityPostLang entity) {
			return super.remove(entity);
		}
	}

	@Override
	@Cacheable(value = "contentcache", key = "'activityPost-' + #id")
	public ActivityPost get(long id) {
		var inst = Instant.now();
		ActivityPost activityPost;
		if (! (securityUtils.hasRole(UserRole.ADMINISTRATOR) || securityUtils.hasRole(UserRole.CONTENTMANAGER))) {
			QActivityPost qActivityPost = QActivityPost.activityPost;
			activityPost = repository.findOne(qActivityPost.publishDate.before(inst)
				.andAnyOf(qActivityPost.expirationDate.isNull(), qActivityPost.expirationDate.after(inst))
				.and(qActivityPost.id.eq(id))).orElse(null);
		} else {
			activityPost = repository.findById(id).orElse(null);
		}
		if (activityPost == null) {
			throw new NotFoundElement("Record not found by id=" + id);
		}
		return lazyLoad(activityPost);
	}

	/**
	 * Create and persist a new {@link ActivityPost}
	 */
	@Transactional
	@PreAuthorize("hasRole('ADMINISTRATOR') or hasRole('CONTENTMANAGER')")
	@CacheEvict(value = "contentcache", allEntries = true)
	@Override
	public ActivityPost create(ActivityPost activityPost) {
		return lazyLoad(createFast(activityPost));
	}

	@Override
	@Transactional
	@PreAuthorize("hasRole('ADMINISTRATOR') or hasRole('CONTENTMANAGER')")
	@CacheEvict(value = "contentcache", allEntries = true)
	public ActivityPost createFast(ActivityPost source) {
		final ActivityPost newPost = new ActivityPost();
		newPost.apply(source);
		return updatePostData(newPost, newPost.getTitle(), newPost.getSummary(), newPost.getBody());
	}

	@Override
	@Transactional(readOnly = false)
	@PreAuthorize("hasRole('ADMINISTRATOR') or hasRole('CONTENTMANAGER')")
	@CacheEvict(value = "contentcache", allEntries = true)
	public ActivityPost updateFast(ActivityPost updated, ActivityPost target) {
		target.apply(updated);
		return updatePostData(target, target.getTitle(), target.getSummary(), target.getBody());
	}

	@Override
	@Transactional(readOnly = false)
	@PreAuthorize("hasRole('ADMINISTRATOR') or hasRole('CONTENTMANAGER')")
	@CacheEvict(value = "contentcache", allEntries = true)
	public ActivityPost update(ActivityPost updated, ActivityPost target) {
		return lazyLoad(updateFast(updated, target));
	}

	@Override
	@Cacheable(value = "contentcache")
	public Page<ActivityPost> list(ActivityPostFilter filter, Pageable pageable) {
		final BooleanBuilder predicate = new BooleanBuilder();
		if (filter != null) {
			predicate.and(filter.buildPredicate());
		}
		if (! (securityUtils.hasRole(UserRole.ADMINISTRATOR) || securityUtils.hasRole(UserRole.CONTENTMANAGER))) {
			QActivityPost qActivityPost = QActivityPost.activityPost;
			predicate.and(qActivityPost.publishDate.before(Instant.now())
				.andAnyOf(qActivityPost.expirationDate.isNull(), qActivityPost.expirationDate.after(Instant.now())));
		}
		Page<ActivityPost> activityPostPage = repository.findAll(predicate, pageable);
		activityPostPage.forEach(this::lazyLoad);
		return activityPostPage;
	}

	private ActivityPost updatePostData(ActivityPost post, String title, String summary, String body) {
		post.setTitle(htmlSanitizer.sanitize(title));
		post.setSummary(summary); // summary is in Markdown
		post.setBody(htmlSanitizer.sanitize(body));

		return lazyLoad(repository.save(post));
	}

	@Override
	@Transactional(readOnly = false)
	@PreAuthorize("hasRole('ADMINISTRATOR') or hasRole('CONTENTMANAGER')")
	@CacheEvict(value = "contentcache", allEntries = true)
	public ActivityPost remove(ActivityPost activityPost) {
		if (activityPost == null || activityPost.getId() == null)
			throw new InvalidApiUsageException("An existing ActivityPost must be provided.");

		// Reload
		activityPost = get(activityPost.getId());

		final Path repositoryPath = getActivityPostFolderPath(activityPost);
		try {
			if (! repositoryService.hasPath(repositoryPath)) {
				// No related repository folder, so just delete activity post
				repository.delete(activityPost);
				return activityPost;
			}

			try {
				// Remove all files related to activity post
				for (RepositoryFile file : repositoryService.getFiles(repositoryPath, Sort.unsorted())) {
					repositoryService.removeFile(file);
				}
			} catch (NoSuchRepositoryFileException | IOException e) {
				LOG.warn("Could not delete file from ActivityPost {}", activityPost.getId());
			}

			// Delete activity post
			repository.delete(activityPost);
			// Delete folder
			repositoryService.deleteFolder(repositoryPath);
		} catch (InvalidRepositoryPathException e) {
			// Must be OK
			LOG.warn("Invalid repository path: {}", e.getMessage());
		} catch (FolderNotEmptyException e) {
			LOG.warn("Could not remove news folder: {}", e.getMessage(), e);
		}
		return activityPost;
	}

	@Override
	@Transactional
	@PreAuthorize("hasRole('ADMINISTRATOR') or hasRole('CONTENTMANAGER') or hasPermission(#activityPost, 'ADMINISTRATION')")
	public RepositoryFile uploadActivityPostFile(ActivityPost activityPost, final MultipartFile file) throws IOException, InvalidRepositoryPathException, InvalidRepositoryFileDataException {
		if (file == null)
			throw new InvalidRepositoryPathException("Nothing to upload. File must be provided.");
		if (activityPost == null || activityPost.getId() == null)
			throw new InvalidApiUsageException("An existing ActivityPost must be provided.");

		// Reload
		activityPost = get(activityPost.getId());

		// Ensure news folder
		var activityPostFolder = ensureNewsFolder(activityPost);

		String originalFilename = UUID.randomUUID().toString().concat("_").concat(file.getOriginalFilename());
		LOG.info("Upload file {} to path {}", originalFilename, activityPostFolder.getFolderPath());
		var savedFile = repositoryService.addFile(activityPostFolder.getFolderPath(), originalFilename, file.getContentType(), file.getInputStream(), null);

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

		return savedFile;
	}

	private RepositoryFolder ensureNewsFolder(final ActivityPost activityPost) {
		try {
			final Path repositoryPath = getActivityPostFolderPath(activityPost);
			return asAdminInvoker.invoke(() -> {
				// Ensure target folder exists for the ActivityPost
				return repositoryService.ensureFolder(repositoryPath);
			});
		} catch (Exception e) {
			LOG.warn("Could not create folder: {}", e.getMessage());
			throw new InvalidApiUsageException("Could not create folder", e);
		}
	}

	private Path getActivityPostFolderPath(final ActivityPost activityPost) {
		assert (activityPost != null);
		assert (activityPost.getId() != null);

		return Paths.get("/content", "news", activityPost.getId().toString()).toAbsolutePath();
	}

	protected ActivityPost lazyLoad(ActivityPost activityPost) {
		if(activityPost != null) {
			if (activityPost.getCoverImage() != null) {
				activityPost.getCoverImage().getId();
			}
		}
		return activityPost;
	}


	@Override
	public ActivityPostLang machineTranslate(ActivityPost 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 ActivityPostLang();
		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.getSummary())
				.texts(Map.of(
					"title", new FormattedText(TextFormat.markdown, enTranslation.getTitle()),
					"summary", new FormattedText(TextFormat.html, original.getSummary()),
					"body", new FormattedText(TextFormat.markdown, enTranslation.getBody())
				));

		} else {
			// Translations to English use the original texts
			builder
				.sourceLang(original.getOriginalLanguageTag())
				// .context(original.getSummary())
				.texts(Map.of(
					"title", new FormattedText(TextFormat.html, original.getTitle()),
					"summary", new FormattedText(TextFormat.html, original.getSummary()),
					"body", new FormattedText(TextFormat.html, original.getBody())
				));
		}

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

		if (StringUtils.isNotBlank(original.getTitle())) {
			mt.setTitle(translations.getTexts().get("title"));
		}
		if (StringUtils.isNotBlank(original.getSummary())) {
			mt.setSummary(translations.getTexts().get("summary"));
		}
		if (StringUtils.isNotBlank(original.getBody())) {
			mt.setBody(translations.getTexts().get("body"));
		}
		return mt;
	}

	@Override
	public Page<TranslatedActivityPost> allNews(Pageable page) {
		var inst = Instant.now();
		QActivityPost qActivityPost = QActivityPost.activityPost;
		var posts = repository.findAll(qActivityPost.publishDate.before(inst)
				.andAnyOf(qActivityPost.expirationDate.isNull(), (qActivityPost.expirationDate.after(inst))), page);
		return new PageImpl<>(translationSupport.getTranslated(posts.getContent()), page, posts.getTotalElements());
	}
	
}