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