001/*
002 *  Copyright 2019 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.userdirectory;
017
018import java.io.IOException;
019import java.util.ArrayList;
020import java.util.HashMap;
021import java.util.List;
022import java.util.Map;
023import java.util.Set;
024import java.util.concurrent.ExecutionException;
025import java.util.concurrent.Future;
026import java.util.stream.Collectors;
027
028import org.apache.avalon.framework.component.Component;
029import org.apache.avalon.framework.configuration.Configurable;
030import org.apache.avalon.framework.configuration.Configuration;
031import org.apache.avalon.framework.configuration.ConfigurationException;
032import org.apache.avalon.framework.service.ServiceException;
033import org.apache.avalon.framework.service.ServiceManager;
034import org.apache.avalon.framework.service.Serviceable;
035import org.apache.commons.collections4.MapUtils;
036import org.apache.solr.client.solrj.SolrServerException;
037import org.slf4j.Logger;
038
039import org.ametys.cms.ObservationConstants;
040import org.ametys.cms.clientsideelement.content.SmartContentClientSideElementHelper;
041import org.ametys.cms.content.indexing.solr.SolrIndexer;
042import org.ametys.cms.repository.Content;
043import org.ametys.core.observation.Event;
044import org.ametys.core.observation.ObservationManager;
045import org.ametys.core.user.CurrentUserProvider;
046import org.ametys.plugins.repository.AmetysObjectResolver;
047import org.ametys.plugins.repository.ModifiableAmetysObject;
048import org.ametys.plugins.repository.RemovableAmetysObject;
049import org.ametys.plugins.repository.lock.LockableAmetysObject;
050
051/**
052 * Delete UD content component
053 */
054public abstract class AbstractDeleteUDContentComponent implements Component, Serviceable, Configurable
055{
056    private static final int _REMOVE_REFERENCE_DEFAULT_ACTION_ID = 200;
057    
058    /** The Solr indexer. */
059    protected SolrIndexer _solrIndexer;
060    
061    /** The Ametys object resolver */
062    protected AmetysObjectResolver _resolver;
063    
064    /** The observation manager */
065    protected ObservationManager _observationManager;
066    
067    /** The current user provider */
068    protected CurrentUserProvider _currentUserProvider;
069    
070    /** Helper for smart content client elements */
071    protected SmartContentClientSideElementHelper _smartHelper;
072    
073    /** The action id to call when references are removed */
074    protected int _removeReferenceActionId;
075    
076    @Override
077    public void service(ServiceManager smanager) throws ServiceException
078    {
079        _solrIndexer = (SolrIndexer) smanager.lookup(SolrIndexer.ROLE);
080        _resolver = (AmetysObjectResolver) smanager.lookup(AmetysObjectResolver.ROLE);
081        _observationManager = (ObservationManager) smanager.lookup(ObservationManager.ROLE);
082        _currentUserProvider = (CurrentUserProvider) smanager.lookup(CurrentUserProvider.ROLE);
083        _smartHelper = (SmartContentClientSideElementHelper) smanager.lookup(SmartContentClientSideElementHelper.ROLE);
084    }
085    
086    @Override
087    public void configure(Configuration configuration) throws ConfigurationException
088    {
089        Configuration conf = configuration.getChild("removeReferenceActionId");
090        _removeReferenceActionId = conf.getValueAsInteger(_REMOVE_REFERENCE_DEFAULT_ACTION_ID);
091    }
092    
093    /**
094     * Delete contents and logs results
095     * @param contentsToRemove the list of contents to remove
096     * @param parameters the additional parameters
097     * @param rights the map of rights id with its prefix
098     * @param logger The logger
099     * @return the number of deleted contents
100     */
101    @SuppressWarnings("unchecked")
102    public int deleteContentsWithLog(List<Content> contentsToRemove, Map<String, Object> parameters, Map<String, String> rights, Logger logger)
103    {
104        int nbDeletedContents = 0;
105        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()));
106        
107        List<String> contentIds = contentsToRemove.stream()
108                .map(Content::getId)
109                .collect(Collectors.toList());
110        
111        logger.info("Trying to delete contents. This can take a while...");
112        Map<String, Object> deleteResults = deleteContents(contentIds, MapUtils.EMPTY_SORTED_MAP, MapUtils.EMPTY_SORTED_MAP, logger);
113        logger.info("Contents deleting process ended.");
114        
115        for (String contentId : contentIds)
116        {
117            Map<String, Object> result = (Map<String, Object>) deleteResults.get(contentId);
118            List<String> deletedContents = (List<String>) result.get("deleted-contents");
119            nbDeletedContents += deletedContents.size();
120            
121            List<Content> referencedContents = (List<Content>) result.get("referenced-contents");
122            if (referencedContents.size() > 0)
123            {
124                logger.info("The following contents cannot be deleted because they are referenced: {}", referencedContents.stream().map(c -> c.getId()).collect(Collectors.toList()));
125            }
126            
127            List<Content> lockedContents = (List<Content>) result.get("locked-contents");
128            if (lockedContents.size() > 0)
129            {
130                logger.info("The following contents cannot be deleted because they are locked: {}", lockedContents.stream().map(c -> c.getId()).collect(Collectors.toList()));
131            }
132            
133            List<Content> undeletedContents = (List<Content>) result.get("undeleted-contents");
134            if (undeletedContents.size() > 0)
135            {
136                logger.info("{} contents were not deleted. See previous logs for more information.", undeletedContents.size());
137            }
138        }
139        
140        return nbDeletedContents;
141    }
142    
143    /**
144     * Delete contents
145     * @param contentsId The ids of contents to delete
146     * @param parameters the additional parameters
147     * @param rights the map of rights id with its prefix
148     * @param logger The logger
149     * @return the deleted and undeleted contents
150     */
151    public Map<String, Object> deleteContents(List<String> contentsId, Map<String, Object> parameters, Map<String, String> rights, Logger logger)
152    {
153        Map<String, Object> results = new HashMap<>();
154        
155        List<String> alreadyDeletedContentIds = new ArrayList<>();
156        for (String contentId : contentsId)
157        {
158            if (!alreadyDeletedContentIds.contains(contentId))
159            {
160                Content content = _resolver.resolveById(contentId);
161                
162                Map<String, Object> result = new HashMap<>();
163                result.put("deleted-contents", new ArrayList<String>());
164                result.put("undeleted-contents", new ArrayList<Content>());
165                result.put("referenced-contents", new ArrayList<Content>());
166                result.put("unauthorized-contents", new ArrayList<Content>());
167                result.put("locked-contents", new ArrayList<Content>());
168                result.put("initial-content", content.getId());
169                results.put(contentId, result);
170                
171                boolean referenced = isContentReferenced(content, logger);
172                if (referenced || !_checkBeforeDeletion(content, rights, result, logger))
173                {
174                    if (referenced)
175                    {
176                        // Indicate that the content is referenced.
177                        @SuppressWarnings("unchecked")
178                        List<Content> referencedContents = (List<Content>) result.get("referenced-contents");
179                        referencedContents.add(content);
180                    }
181                    result.put("check-before-deletion-failed", true);
182                }
183                else
184                {
185                    // Process deletion
186                    _deleteContent(content, parameters, rights, result, logger);
187
188                    @SuppressWarnings("unchecked")
189                    List<String> deletedContents = (List<String>) result.get("deleted-contents");
190                    if (deletedContents != null)
191                    {
192                        alreadyDeletedContentIds.addAll(deletedContents);
193                        
194                    }
195                }
196            }
197            else
198            {
199                logger.info("Content with id '{}' has been already deleted during its parent deletion", contentId);
200            }
201        }
202
203        return results;
204    }
205
206    /**
207     * Delete one content
208     * @param content the content to delete
209     * @param parameters the additional parameters
210     * @param rights the map of rights id with its prefix
211     * @param results the results map
212     * @param logger The logger
213     */
214    protected void _deleteContent(Content content, Map<String, Object> parameters, Map<String, String> rights, Map<String, Object> results, Logger logger)
215    {
216        // 1 - First remove relations
217        boolean success = _removeRelations(content, logger);
218        
219        // 2 - If succeed, process to deletion
220        if (success)
221        {
222            _processContentDeletion(content, rights, results, logger);
223        }
224        else
225        {
226            @SuppressWarnings("unchecked")
227            List<Content> undeletedContents = (List<Content>) results.get("undeleted-contents");
228            undeletedContents.add(content);
229            
230            logger.warn("Can not delete content {} ('{}') : at least one relation to contents could not be removed", content.getTitle(), content.getId());
231        }
232    }
233
234    /**
235     * Delete one content
236     * @param content the content to delete
237     * @param rights the map of rights id with its prefix
238     * @param results the results map
239     * @param logger The logger
240     */
241    @SuppressWarnings("unchecked")
242    protected void _processContentDeletion(Content content, Map<String, String> rights, Map<String, Object> results, Logger logger)
243    {
244        Set<String> toDelete = _getContentIdsToDelete(content, rights, results, logger);
245        
246        List<Content> referencedContents = (List<Content>) results.get("referenced-contents");
247        List<Content> lockedContents = (List<Content>) results.get("locked-contents");
248        List<Content> unauthorizedContents = (List<Content>) results.get("unauthorized-contents");
249        
250        if (referencedContents.size() == 0 && lockedContents.size() == 0 && unauthorizedContents.size() == 0)
251        {
252            _finalizeDeleteContents(toDelete, content.getParent(), results, logger);
253        }
254    }
255    
256    /**
257     * Finalize the deletion of contents. Call observers and remove contents
258     * @param contentIdsToDelete the list of content id to delete
259     * @param parent the jcr parent for saving changes
260     * @param results the results map
261     * @param logger The logger
262     */
263    protected void _finalizeDeleteContents(Set<String> contentIdsToDelete, ModifiableAmetysObject parent, Map<String, Object> results, Logger logger)
264    {
265        @SuppressWarnings("unchecked")
266        List<Content> unauthorizedContents = (List<Content>) results.get("unauthorized-contents");
267        @SuppressWarnings("unchecked")
268        List<Content> lockedContents = (List<Content>) results.get("locked-contents");
269        
270        if (!unauthorizedContents.isEmpty() || !lockedContents.isEmpty())
271        {
272            //Do Nothing
273            return;
274        }
275        
276        try
277        {
278            _observationManager.addArgumentForEvents(new String[] {ObservationConstants.EVENT_CONTENT_DELETED}, ObservationConstants.ARGS_CONTENT_COMMIT, false);
279            
280            Map<String, Map<String, Object>> eventParams = new HashMap<>();
281            for (String id : contentIdsToDelete)
282            {
283                Content content = _resolver.resolveById(id);
284                Map<String, Object> eventParam = _getEventParametersForDeletion(content);
285                eventParams.put(id, eventParam);
286                
287                _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_DELETING, _currentUserProvider.getUser(), eventParam));
288            }
289            
290            for (String id : contentIdsToDelete)
291            {
292                Content content = _resolver.resolveById(id);
293                
294                // Remove the content.
295                LockableAmetysObject lockedContent = (LockableAmetysObject) content;
296                if (lockedContent.isLocked())
297                {
298                    lockedContent.unlock();
299                }
300                
301                ((RemovableAmetysObject) content).remove();
302            }
303            
304            parent.saveChanges();
305            
306            for (String id : contentIdsToDelete)
307            {
308                _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_DELETED, _currentUserProvider.getUser(), eventParams.get(id)));
309                
310                @SuppressWarnings("unchecked")
311                List<String> deletedContents = (List<String>) results.get("deleted-contents");
312                deletedContents.add(id);
313            }
314        }
315        finally 
316        {
317            _observationManager.removeArgumentForEvents(new String[] {ObservationConstants.EVENT_CONTENT_DELETED}, ObservationConstants.ARGS_CONTENT_COMMIT);
318            _commitAllChanges(logger);
319        }
320    }
321    
322    /**
323     * True if we can delete the content (check if removable, rights and if locked)
324     * @param content the content
325     * @param rights the map of rights id with its prefix
326     * @param results the results map
327     * @return true if we can delete the content
328     */
329    protected boolean _canDeleteContent(Content content, Map<String, String> rights, Map<String, Object> results)
330    {
331        if (!(content instanceof RemovableAmetysObject))
332        {
333            throw new IllegalArgumentException("The content [" + content.getId() + "] is not a RemovableAmetysObject, it can't be deleted.");
334        }
335        
336        if (!_hasRight(content, rights))
337        {
338            // User has no sufficient right
339            @SuppressWarnings("unchecked")
340            List<Content> norightContents = (List<Content>) results.get("unauthorized-contents");
341            norightContents.add(content);
342            
343            return false;
344        }
345        else if (_isLocked(content))
346        {
347            @SuppressWarnings("unchecked")
348            List<Content> lockedContents = (List<Content>) results.get("locked-contents");
349            lockedContents.add(content);
350            
351            return false;
352        }
353        
354        return true;
355    }
356    
357    /**
358     * Get parameters for content deleted {@link Event}
359     * @param content the removed content
360     * @return the event's parameters
361     */
362    protected Map<String, Object> _getEventParametersForDeletion (Content content)
363    {
364        Map<String, Object> eventParams = new HashMap<>();
365        eventParams.put(ObservationConstants.ARGS_CONTENT, content);
366        eventParams.put(ObservationConstants.ARGS_CONTENT_NAME, content.getName());
367        eventParams.put(ObservationConstants.ARGS_CONTENT_ID, content.getId());
368        return eventParams;
369    }
370    
371    /**
372     * Commit all changes in solr
373     * @param logger The logger
374     */
375    protected void _commitAllChanges(Logger logger)
376    {
377        // Before trying to commit, be sure all the async observers of the current request are finished
378        for (Future future : _observationManager.getFuturesForRequest())
379        {
380            try
381            {
382                future.get();
383            }
384            catch (ExecutionException | InterruptedException e)
385            {
386                logger.info("An exception occured when calling #get() on Future result of an observer." , e);
387            }
388        }
389        
390        // Commit all uncommited changes
391        try
392        {
393            _solrIndexer.commit();
394            
395            logger.info("Deleted contents are now committed into Solr.");
396        }
397        catch (IOException | SolrServerException e)
398        {
399            logger.error("Impossible to commit changes", e);
400        }
401    }
402    
403    /**
404     * Determines if the content is locked
405     * @param content the content
406     * @return true if the content is locked
407     */
408    protected boolean _isLocked(Content content)
409    {
410        return _smartHelper.isLocked(content);
411    }
412    
413    /**
414     * Determines if the user has sufficient right for the given content
415     * @param content the content
416     * @param rights the map of rights id with its prefix
417     * @return true if user has sufficient right
418     */
419    protected boolean _hasRight(Content content, Map<String, String> rights)
420    {
421        if (rights.isEmpty())
422        {
423            return true;
424        }
425        
426        return _smartHelper.hasRight(rights, content);
427    }
428    
429    /**
430     * True if the content is referenced
431     * @param content the content
432     * @param logger The logger
433     * @return true if the content is referenced
434     */
435    public abstract boolean isContentReferenced(Content content, Logger logger);
436
437    /**
438     * Check that deletion can be performed without blocking errors
439     * @param content The initial content to delete
440     * @param rights the map of rights id with its prefix
441     * @param results The results
442     * @param logger The logger
443     * @return true if the deletion can be performed
444     */
445    protected abstract boolean _checkBeforeDeletion(Content content, Map<String, String> rights, Map<String, Object> results, Logger logger);
446    
447    /**
448     * Remove relations
449     * @param content the content
450     * @param logger The logger
451     * @return <code>true</code> if all relations have been removed
452     */
453    protected abstract boolean _removeRelations(Content content, Logger logger);
454    
455    /**
456     * Get the id of children to be deleted. 
457     * All children shared with other contents which are not part of deletion, will be not deleted.
458     * @param content The content to delete
459     * @param rights the map of rights id with its prefix
460     * @param results The results
461     * @param logger The logger
462     * @return The id of contents to be deleted
463     */
464    protected abstract Set<String> _getContentIdsToDelete (Content content, Map<String, String> rights, Map<String, Object> results, Logger logger);
465    
466}