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