001/*
002 *  Copyright 2016 Anyware Services
003 *
004 *  Licensed under the Apache License, Version 2.0 (the "License");
005 *  you may not use this file except in compliance with the License.
006 *  You may obtain a copy of the License at
007 *
008 *      http://www.apache.org/licenses/LICENSE-2.0
009 *
010 *  Unless required by applicable law or agreed to in writing, software
011 *  distributed under the License is distributed on an "AS IS" BASIS,
012 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 *  See the License for the specific language governing permissions and
014 *  limitations under the License.
015 */
016package org.ametys.plugins.contentio.synchronize;
017
018import java.io.IOException;
019import java.util.ArrayList;
020import java.util.Arrays;
021import java.util.HashMap;
022import java.util.List;
023import java.util.Map;
024import java.util.stream.Collectors;
025
026import org.apache.avalon.framework.service.ServiceException;
027import org.apache.avalon.framework.service.ServiceManager;
028import org.apache.commons.lang3.StringUtils;
029import org.slf4j.Logger;
030
031import org.ametys.cms.repository.Content;
032import org.ametys.cms.repository.ContentQueryHelper;
033import org.ametys.cms.repository.ContentTypeExpression;
034import org.ametys.cms.repository.LanguageExpression;
035import org.ametys.cms.repository.ModifiableContent;
036import org.ametys.cms.repository.WorkflowAwareContent;
037import org.ametys.cms.workflow.ContentWorkflowHelper;
038import org.ametys.core.observation.Event;
039import org.ametys.core.observation.ObservationManager;
040import org.ametys.core.schedule.progression.ContainerProgressionTracker;
041import org.ametys.core.schedule.progression.ProgressionTrackerFactory;
042import org.ametys.core.schedule.progression.SimpleProgressionTracker;
043import org.ametys.core.user.CurrentUserProvider;
044import org.ametys.core.util.HttpUtils;
045import org.ametys.core.util.I18nUtils;
046import org.ametys.core.util.mail.SendMailHelper;
047import org.ametys.plugins.repository.AmetysObjectIterable;
048import org.ametys.plugins.repository.query.expression.AndExpression;
049import org.ametys.plugins.repository.query.expression.Expression;
050import org.ametys.plugins.repository.query.expression.Expression.Operator;
051import org.ametys.plugins.repository.query.expression.OrExpression;
052import org.ametys.plugins.repository.query.expression.StringExpression;
053import org.ametys.plugins.workflow.AbstractWorkflowComponent;
054import org.ametys.plugins.workflow.AbstractWorkflowComponent.ConditionFailure;
055import org.ametys.runtime.config.Config;
056import org.ametys.runtime.i18n.I18nizableText;
057
058import com.opensymphony.workflow.InvalidActionException;
059import com.opensymphony.workflow.WorkflowException;
060
061import jakarta.mail.MessagingException;
062
063/**
064 * Abstract implementation of {@link SynchronizableContentsCollection}.
065 */
066public abstract class AbstractSynchronizableContentsCollection extends AbstractStaticSynchronizableContentsCollection
067{
068    /** SCC unique ID field */
069    protected static final String SCC_UNIQUE_ID = "scc$uniqueid";
070    
071    /** The i18n utils */
072    protected I18nUtils _i18nUtils;
073    /** The current user provider */
074    protected CurrentUserProvider _currentUserProvider;
075    /** The observation manager */
076    protected ObservationManager _observationManager;
077    /** The content workflow helper */
078    protected ContentWorkflowHelper _contentWorkflowHelper;
079    
080    /** Number of errors encountered */
081    protected int _nbError;
082    /** True if there is a global error during synchronization */
083    protected boolean _hasGlobalError;
084    
085    /** Number of created contents */
086    protected int _nbCreatedContents;
087    /** Number of synchronized contents */
088    protected int _nbSynchronizedContents;
089    /** Number of unchanged contents */
090    protected int _nbNotChangedContents;
091    /** Number of deleted contents */
092    protected int _nbDeletedContents;
093    
094    @Override
095    public void service(ServiceManager manager) throws ServiceException
096    {
097        super.service(manager);
098        _i18nUtils = (I18nUtils) manager.lookup(I18nUtils.ROLE);
099        _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE);
100        _observationManager = (ObservationManager) manager.lookup(ObservationManager.ROLE);
101        _contentWorkflowHelper = (ContentWorkflowHelper) manager.lookup(ContentWorkflowHelper.ROLE);
102    }
103    
104    @Override
105    public List<ModifiableContent> populate(Logger logger, ContainerProgressionTracker progressionTracker)
106    {
107        ContainerProgressionTracker internalPopulaitePT =  progressionTracker.addContainerStep("internalpopulate", new I18nizableText("plugin.contentio", "PLUGINS_CONTENTIO_SCHEDULER_SYNCHRONIZE_COLLECTION_IMPORT_SYNCHRONIZE_CONTENTS_STEP_LABEL"), 2);
108        
109        SimpleProgressionTracker deleteUnexistingContentsPT = progressionTracker.addSimpleStep("deleteunexistingcontents", new I18nizableText("plugin.contentio", "PLUGINS_CONTENTIO_SCHEDULER_SYNCHRONIZE_COLLECTION_DELETE_UNEXISTING_CONTENTS_STEP_LABEL"));
110        
111        SimpleProgressionTracker notifyPT = progressionTracker.addSimpleStep("notify", new I18nizableText("plugin.contentio", "PLUGINS_CONTENTIO_SCHEDULER_SYNCHRONIZE_COLLECTION_NOTIFY_OBSERVERS_AND_SEND_MAILS_STEP_LABEL"));
112        
113        _nbCreatedContents = 0;
114        _nbSynchronizedContents = 0;
115        _nbNotChangedContents = 0;
116        _nbDeletedContents = 0;
117        _nbError = 0;
118        _hasGlobalError = false;
119        
120        logger.info("Start synchronization of collection '{}'", getId());
121        List<Long> times = new ArrayList<>();
122        times.add(System.currentTimeMillis());
123        
124        // Do populate
125        List<ModifiableContent> populatedContents = _internalPopulate(logger, internalPopulaitePT);
126        
127        if (!_hasGlobalError && removalSync())
128        {
129            // Delete old contents if source prevails
130            deleteUnexistingContents(logger);
131        }
132        deleteUnexistingContentsPT.increment();
133        
134        times.add(System.currentTimeMillis());
135        logger.info("[Synchronization of collection '{}'] Populated in {} ms", getId(), times.get(times.size() - 1) - times.get(times.size() - 2));
136        _logSynchronizationResult(logger);
137        
138        notifyPT.setSize(2);
139        
140        if (_hasSomethingChanged())
141        {
142            // Do not notify obeservers if there is no change
143            Map<String, Object> eventParams = new HashMap<>();
144            eventParams.put(org.ametys.plugins.contentio.synchronize.observation.ObservationConstants.ARGS_COLLECTION_ID, this.getId());
145            eventParams.put(org.ametys.plugins.contentio.synchronize.observation.ObservationConstants.ARGS_COLLECTION_CONTENT_TYPE, this.getContentType());
146            _observationManager.notify(new Event(org.ametys.plugins.contentio.synchronize.observation.ObservationConstants.EVENT_CONTENT_COLLECTION_SYNCHRONIZED, _currentUserProvider.getUser(), eventParams));
147            
148            times.add(System.currentTimeMillis());
149            logger.info("[Synchronization of collection '{}'] Listeners notified in {} ms", getId(), times.get(times.size() - 1) - times.get(times.size() - 2));
150            
151        }
152        notifyPT.increment();
153        
154        if (_nbError > 0 && getReportMails().length() > 0)
155        {
156            try
157            {
158                logger.warn("{} contents were not created/updated because of an error.", _nbError);
159                sendErrorMail(_nbError);
160                
161                times.add(System.currentTimeMillis());
162                logger.info("[Synchronization of collection '{}'] Error mail sent in {} ms", getId(), times.get(times.size() - 1) - times.get(times.size() - 2));
163            }
164            catch (MessagingException | IOException e)
165            {
166                logger.warn("Unable to send mail", e);
167            }
168        }
169        notifyPT.increment();
170        
171        logger.info("[Synchronization of collection '{}'] Total in {} ms", getId(), times.get(times.size() - 1) - times.get(0));
172
173        return populatedContents;
174    }
175    
176    /**
177     * Internal implementation of {@link #populate(Logger, ContainerProgressionTracker)}
178     * @param logger The logger
179     * @return The list of created/synchronized contents
180     */
181    protected List<ModifiableContent> _internalPopulate(Logger logger)
182    {
183        return _internalPopulate(logger, ProgressionTrackerFactory.createContainerProgressionTracker("Internal populate", logger));
184    }
185    
186    /**
187     * Internal implementation of {@link #populate(Logger, ContainerProgressionTracker)}
188     * @param logger The logger
189     * @param progressionTracker The progression tracker
190     * @return The list of created/synchronized contents
191     */
192    protected abstract List<ModifiableContent> _internalPopulate(Logger logger, ContainerProgressionTracker progressionTracker);
193    
194    @Override
195    public void empty(Logger logger)
196    {
197        // Get all contents from the SCC
198        Expression collectionExpression = _sccHelper.getCollectionExpression(getId());
199        String xPathQuery = ContentQueryHelper.getContentXPathQuery(collectionExpression);
200        List<ModifiableContent> contentsToRemove = _resolver.<ModifiableContent>query(xPathQuery)
201            .stream()
202            .toList();
203        // Process to the deletion
204        _removeSCCOrDeleteContents(contentsToRemove, logger);
205    }
206    
207    /**
208     * Delete contents created by a previous synchronization which does not exist anymore in remote source.
209     * If the content belongs to several SCC, only the current SCC reference will be deleted.
210     * @param logger The logger
211     */
212    protected void deleteUnexistingContents(Logger logger)
213    {
214        String query = _getContentPathQuery(null, null, null, true);
215        AmetysObjectIterable<ModifiableContent> contents = _resolver.query(query);
216        
217        List<ModifiableContent> contentsToRemove = _getContentsToRemove(contents);
218
219        if (!contentsToRemove.isEmpty())
220        {
221            if (logger.isInfoEnabled())
222            {
223                contentsToRemove.stream().forEach(content -> logger.info("The content '{}' ({}) does not exist anymore in remote source: it will be deleted if possible.", content.getTitle(), content.getId()));
224            }
225            
226            _nbDeletedContents = _removeSCCOrDeleteContents(contentsToRemove, logger);
227        }
228    }
229    
230    /**
231     * Filter the contents to remove.
232     * @param contents The list of all the available contents
233     * @return The {@link List} of {@link Content} to remove.
234     */
235    protected abstract List<ModifiableContent> _getContentsToRemove(AmetysObjectIterable<ModifiableContent> contents);
236    
237    /**
238     * For each content, if the content has only this SCC, try to delete it, but if it has several SCC, remove the current collection from the SCC property.
239     * @param contents The contents to delete or to remove the SCC
240     * @param logger The logger
241     * @return the number of really deleted contents
242     */
243    protected int _removeSCCOrDeleteContents(List<ModifiableContent> contents, Logger logger)
244    {
245        logger.info("Remove SCC on contents with multiple SCC... {} contents for all the deletion process.", contents.size());
246        List<Content> contentsToReallyDelete = new ArrayList<>();
247        for (ModifiableContent content : contents)
248        {
249            // There is more than one SCC, only remove the SCC reference
250            if (_sccHelper.getSynchronizableCollectionIds(content).size() > 1)
251            {
252                _sccHelper.removeSCCProperty(content, getId());
253                content.saveChanges();
254            }
255            // It was the only SCC, completely remove the content
256            else
257            {
258                contentsToReallyDelete.add(content);
259            }
260        }
261
262        logger.info("Remove contents with single SCC... {} contents remaining.", contentsToReallyDelete.size());
263        int nbDeletedContents = _deleteContents(contentsToReallyDelete, logger);
264        logger.info("Contents deleting process ended. {} contents has been deleted.", nbDeletedContents);
265        
266        return nbDeletedContents;
267    }
268    
269    /**
270     * Delete contents.
271     * @param contentsToRemove List of contents to remove
272     * @param logger The logger
273     * @return the number of deleted contents
274     */
275    protected int _deleteContents(List<Content> contentsToRemove, Logger logger)
276    {
277        return _contentDAO.forceDeleteContentsWithLog(contentsToRemove, null, logger);
278    }
279    
280    /**
281     * Logs the result of the synchronization, containing
282     * <ul>
283     * <li>The number of created contents</li>
284     * <li>The number of synchronized contents</li>
285     * <li>The number of unchanged contents</li>
286     * <li>The number of deleted contents</li>
287     * </ul>
288     * @param logger the logger
289     */
290    protected void _logSynchronizationResult(Logger logger)
291    {
292        logger.info("{} contents were created", _nbCreatedContents);
293        logger.info("{} contents were updated", _nbSynchronizedContents);
294        logger.info("{} contents did not changed", _nbNotChangedContents);
295        logger.info("{} contents were deleted", _nbDeletedContents);
296    }
297    
298    /**
299     * Checks if some content have changed during the synchronization
300     * @return <code>true</code> if some contents have changed, <code>false</code> otherwise
301     */
302    protected boolean _hasSomethingChanged()
303    {
304        return _nbCreatedContents > 0 || _nbSynchronizedContents > 0 || _nbDeletedContents > 0;
305    }
306    
307    /**
308     * Sends the report mails
309     * @param nbError The number of error
310     * @throws MessagingException if a messaging error occurred
311     * @throws IOException if an error occurred building the message
312     */
313    protected void sendErrorMail(int nbError) throws MessagingException, IOException
314    {
315        String pluginName = "plugin.contentio";
316        List<String> params = new ArrayList<>();
317        params.add(getId());
318        String subject = _i18nUtils.translate(new I18nizableText(pluginName, "PLUGINS_CONTENTIO_POPULATE_REPORT_MAIL_SUBJECT", params));
319        
320        params.clear();
321        params.add(String.valueOf(nbError));
322        params.add(getId());
323        String baseUrl = HttpUtils.sanitize(Config.getInstance().getValue("cms.url"));
324        params.add(baseUrl + "/_admin/index.html?uitool=uitool-admin-logs");
325        String body = _i18nUtils.translate(new I18nizableText(pluginName, "PLUGINS_CONTENTIO_POPULATE_REPORT_MAIL_BODY", params));
326        
327        SendMailHelper.newMail()
328                      .withSubject(subject)
329                      .withTextBody(body)
330                      .withRecipients(Arrays.asList(getReportMails().split("\\n")))
331                      .sendMail();
332    }
333    
334    /**
335     * Validates a content after import
336     * @param content The content to validate
337     * @param validationActionId Validation action ID to use for this content
338     * @param logger The logger
339     */
340    protected void validateContent(WorkflowAwareContent content, int validationActionId, Logger logger)
341    {
342        Map<String, Object> inputs = new HashMap<>();
343        
344        try
345        {
346            _contentWorkflowHelper.doAction(content, validationActionId, inputs);
347            logger.info("The content {} has been validated after import", content);
348        }
349        catch (WorkflowException | InvalidActionException e)
350        {
351            String failuresAsString = _getActionFailuresAsString(inputs);
352            logger.error("The content {} cannot be validated after import{}", content, failuresAsString, e);
353        }
354    }
355    
356    private String _getActionFailuresAsString(Map<String, Object> actionInputs)
357    {
358        String failuresAsString = "";
359        if (actionInputs.containsKey(AbstractWorkflowComponent.FAIL_CONDITIONS_KEY))
360        {
361            @SuppressWarnings("unchecked")
362            List<ConditionFailure> failures = (List<ConditionFailure>) actionInputs.get(AbstractWorkflowComponent.FAIL_CONDITIONS_KEY);
363            if (!failures.isEmpty())
364            {
365                failuresAsString = ", due to the following error(s):\n" + String.join("\n", failures.stream().map(ConditionFailure::text).toList());
366            }
367        }
368        
369        return failuresAsString;
370    }
371    
372    /**
373     * Creates content action with result from request
374     * @param contentType Type of the content to create
375     * @param workflowName Workflow to use for this content
376     * @param initialActionId Action ID for initialization
377     * @param lang The language
378     * @param contentTitle The content title
379     * @param logger The logger
380     * @return The content id, or null of a workflow error occured
381     */
382    protected ModifiableContent createContentAction(String contentType, String workflowName, int initialActionId, String lang, String contentTitle, Logger logger)
383    {
384        try
385        {
386            logger.info("Creating content '{}' with the content type '{}' for language {}", contentTitle, getContentType(), lang);
387            String desiredContentName = _contentPrefix + "-" + contentTitle + "-" + lang;
388            
389            Map<String, Object> inputs = _getAdditionalInputsForContentCreation();
390            Map<String, Object> result = _contentWorkflowHelper.createContent(
391                    workflowName,
392                    initialActionId,
393                    desiredContentName,
394                    contentTitle,
395                    new String[] {contentType},
396                    null,
397                    lang,
398                    inputs);
399            
400            return (ModifiableContent) result.get(Content.class.getName());
401        }
402        catch (WorkflowException e)
403        {
404            _nbError++;
405            logger.error("Failed to initialize workflow for content {} and language {}", contentTitle, lang, e);
406            return null;
407        }
408    }
409    
410    /**
411     * Retrieves additional inputs for content creation
412     * @return the additional inputs for content creation
413     */
414    protected Map<String, Object> _getAdditionalInputsForContentCreation()
415    {
416        // no additional inputs by default
417        return new HashMap<>();
418    }
419
420    /**
421     * Construct the query to retrieve the content.
422     * @param lang Lang
423     * @param idValue Synchronization value
424     * @param contentType Content type
425     * @param forceStrictCheck <code>true</code> to force strict mode to check the collection, otherwise it read the "checkCollection" option
426     * @return The {@link List} of {@link Expression}
427     */
428    protected List<Expression> _getExpressionsList(String lang, String idValue, String contentType, boolean forceStrictCheck)
429    {
430        List<Expression> expList = new ArrayList<>();
431        
432        if (forceStrictCheck)
433        {
434            expList.add(_sccHelper.getCollectionExpression(getId()));
435        }
436        else if (checkCollection())
437        {
438            expList.add(
439                getCompatibleSCC(true).stream()
440                    .map(_sccHelper::getCollectionExpression)
441                    .collect(Collectors.toCollection(OrExpression::new))
442            );
443        }
444        
445        if (StringUtils.isNotBlank(contentType))
446        {
447            expList.add(new ContentTypeExpression(Operator.EQ, contentType));
448        }
449        
450        if (StringUtils.isNotBlank(idValue))
451        {
452            expList.add(new StringExpression(getIdField(), Operator.EQ, idValue));
453        }
454        
455        if (StringUtils.isNotBlank(lang))
456        {
457            expList.add(new LanguageExpression(Operator.EQ, lang));
458        }
459        
460        return expList;
461    }
462    
463    /**
464     * Construct the query to retrieve the content.
465     * @param lang Lang
466     * @param idValue Synchronization value
467     * @param contentType Content type
468     * @param forceStrictCheck <code>true</code> to force strict mode to check the collection, otherwise it read the "checkCollection" option
469     * @return The XPATH query
470     */
471    protected String _getContentPathQuery(String lang, String idValue, String contentType, boolean forceStrictCheck)
472    {
473        List<Expression> expList = _getExpressionsList(lang, idValue, contentType, forceStrictCheck);
474        AndExpression andExp = new AndExpression(expList);
475        return ContentQueryHelper.getContentXPathQuery(andExp);
476    }
477    
478    /**
479     * Remove empty parameters to the map
480     * @param searchParameters the parameters
481     * @return the map of none empty parameters
482     */
483    protected Map<String, Object> _removeEmptyParameters(Map<String, Object> searchParameters)
484    {
485        Map<String, Object> result = new HashMap<>();
486        for (String parameterName : searchParameters.keySet())
487        {
488            Object parameterValue = searchParameters.get(parameterName);
489            if (_isParamNotEmpty(parameterValue))
490            {
491                result.put(parameterName, parameterValue);
492            }
493        }
494        
495        return result;
496    }
497    
498    /**
499     * Check if the parameter value is empty
500     * @param parameterValue the parameter value
501     * @return true if the parameter value is empty
502     */
503    protected boolean _isParamNotEmpty(Object parameterValue)
504    {
505        return parameterValue != null && !(parameterValue instanceof String && StringUtils.isBlank((String) parameterValue));
506    }
507    
508    public Map<String, Integer> getSynchronizationResult()
509    {
510        Map<String, Integer> result = new HashMap<>();
511        
512        result.put(RESULT_NB_CREATED_CONTENTS, _nbCreatedContents);
513        result.put(RESULT_NB_SYNCHRONIZED_CONTENTS, _nbSynchronizedContents);
514        result.put(RESULT_NB_NOT_CHANGED_CONTENTS, _nbNotChangedContents);
515        result.put(RESULT_NB_DELETED_CONTENTS, _nbDeletedContents);
516        
517        return result;
518    }
519}