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        
106        List<String> contentIds = contentsToRemove.stream()
107                .map(Content::getId)
108                .collect(Collectors.toList());
109        
110        logger.info("Trying to delete contents. This can take a while...");
111        Map<String, Object> deleteResults = deleteContents(contentIds, MapUtils.EMPTY_SORTED_MAP, MapUtils.EMPTY_SORTED_MAP, logger);
112        logger.info("Contents deleting process ended.");
113        
114        for (String contentId : contentIds)
115        {
116            Map<String, Object> result = (Map<String, Object>) deleteResults.get(contentId);
117            if (result != null) // if the result is null, the content was already deleted because it's a child of a previous deleted content
118            {
119                List<String> deletedContents = (List<String>) result.get("deleted-contents");
120                nbDeletedContents += deletedContents.size();
121                
122                List<Content> referencedContents = (List<Content>) result.get("referenced-contents");
123                if (referencedContents.size() > 0)
124                {
125                    logger.info("The following contents cannot be deleted because they are referenced: {}", referencedContents.stream().map(c -> c.getId()).collect(Collectors.toList()));
126                }
127                
128                List<Content> lockedContents = (List<Content>) result.get("locked-contents");
129                if (lockedContents.size() > 0)
130                {
131                    logger.info("The following contents cannot be deleted because they are locked: {}", lockedContents.stream().map(c -> c.getId()).collect(Collectors.toList()));
132                }
133                
134                List<Content> undeletedContents = (List<Content>) result.get("undeleted-contents");
135                if (undeletedContents.size() > 0)
136                {
137                    logger.info("{} contents were not deleted. See previous logs for more information.", undeletedContents.size());
138                }
139            }
140        }
141        
142        return nbDeletedContents;
143    }
144    
145    /**
146     * Delete contents
147     * @param contentsId The ids of contents to delete
148     * @param parameters the additional parameters
149     * @param rights the map of rights id with its prefix
150     * @param logger The logger
151     * @return the deleted and undeleted contents
152     */
153    public Map<String, Object> deleteContents(List<String> contentsId, Map<String, Object> parameters, Map<String, String> rights, Logger logger)
154    {
155        Map<String, Object> results = new HashMap<>();
156        
157        List<String> alreadyDeletedContentIds = new ArrayList<>();
158        for (String contentId : contentsId)
159        {
160            if (!alreadyDeletedContentIds.contains(contentId))
161            {
162                Content content = _resolver.resolveById(contentId);
163                
164                Map<String, Object> result = new HashMap<>();
165                result.put("deleted-contents", new ArrayList<String>());
166                result.put("undeleted-contents", new ArrayList<Content>());
167                result.put("referenced-contents", new ArrayList<Content>());
168                result.put("unauthorized-contents", new ArrayList<Content>());
169                result.put("locked-contents", new ArrayList<Content>());
170                result.put("initial-content", content.getId());
171                results.put(contentId, result);
172                
173                boolean referenced = isContentReferenced(content, logger);
174                if (referenced || !_checkBeforeDeletion(content, rights, result, logger))
175                {
176                    if (referenced)
177                    {
178                        // Indicate that the content is referenced.
179                        @SuppressWarnings("unchecked")
180                        List<Content> referencedContents = (List<Content>) result.get("referenced-contents");
181                        referencedContents.add(content);
182                    }
183                    result.put("check-before-deletion-failed", true);
184                }
185                else
186                {
187                    // Process deletion
188                    _deleteContent(content, parameters, rights, result, logger);
189
190                    @SuppressWarnings("unchecked")
191                    List<String> deletedContents = (List<String>) result.get("deleted-contents");
192                    if (deletedContents != null)
193                    {
194                        alreadyDeletedContentIds.addAll(deletedContents);
195                        
196                    }
197                }
198            }
199            else
200            {
201                logger.info("Content with id '{}' has been already deleted during its parent deletion", contentId);
202            }
203        }
204
205        return results;
206    }
207
208    /**
209     * Delete one content
210     * @param content the content to delete
211     * @param parameters the additional parameters
212     * @param rights the map of rights id with its prefix
213     * @param results the results map
214     * @param logger The logger
215     */
216    protected void _deleteContent(Content content, Map<String, Object> parameters, Map<String, String> rights, Map<String, Object> results, Logger logger)
217    {
218        // 1 - First remove relations
219        boolean success = _removeRelations(content, logger);
220        
221        // 2 - If succeed, process to deletion
222        if (success)
223        {
224            _processContentDeletion(content, rights, results, logger);
225        }
226        else
227        {
228            @SuppressWarnings("unchecked")
229            List<Content> undeletedContents = (List<Content>) results.get("undeleted-contents");
230            undeletedContents.add(content);
231            
232            logger.warn("Can not delete content {} ('{}') : at least one relation to contents could not be removed", content.getTitle(), content.getId());
233        }
234    }
235
236    /**
237     * Delete one content
238     * @param content the content to delete
239     * @param rights the map of rights id with its prefix
240     * @param results the results map
241     * @param logger The logger
242     */
243    @SuppressWarnings("unchecked")
244    protected void _processContentDeletion(Content content, Map<String, String> rights, Map<String, Object> results, Logger logger)
245    {
246        Set<String> toDelete = _getContentIdsToDelete(content, rights, results, logger);
247        
248        List<Content> referencedContents = (List<Content>) results.get("referenced-contents");
249        List<Content> lockedContents = (List<Content>) results.get("locked-contents");
250        List<Content> unauthorizedContents = (List<Content>) results.get("unauthorized-contents");
251        
252        if (referencedContents.size() == 0 && lockedContents.size() == 0 && unauthorizedContents.size() == 0)
253        {
254            _finalizeDeleteContents(toDelete, content.getParent(), results, logger);
255        }
256    }
257    
258    /**
259     * Finalize the deletion of contents. Call observers and remove contents
260     * @param contentIdsToDelete the list of content id to delete
261     * @param parent the jcr parent for saving changes
262     * @param results the results map
263     * @param logger The logger
264     */
265    protected void _finalizeDeleteContents(Set<String> contentIdsToDelete, ModifiableAmetysObject parent, Map<String, Object> results, Logger logger)
266    {
267        @SuppressWarnings("unchecked")
268        List<Content> unauthorizedContents = (List<Content>) results.get("unauthorized-contents");
269        @SuppressWarnings("unchecked")
270        List<Content> lockedContents = (List<Content>) results.get("locked-contents");
271        
272        if (!unauthorizedContents.isEmpty() || !lockedContents.isEmpty())
273        {
274            //Do Nothing
275            return;
276        }
277        
278        try
279        {
280            _observationManager.addArgumentForEvents(new String[] {ObservationConstants.EVENT_CONTENT_DELETED}, ObservationConstants.ARGS_CONTENT_COMMIT, false);
281            
282            Map<String, Map<String, Object>> eventParams = new HashMap<>();
283            for (String id : contentIdsToDelete)
284            {
285                Content content = _resolver.resolveById(id);
286                Map<String, Object> eventParam = _getEventParametersForDeletion(content);
287                eventParams.put(id, eventParam);
288                
289                _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_DELETING, _currentUserProvider.getUser(), eventParam));
290            }
291            
292            for (String id : contentIdsToDelete)
293            {
294                Content content = _resolver.resolveById(id);
295                
296                // Remove the content.
297                LockableAmetysObject lockedContent = (LockableAmetysObject) content;
298                if (lockedContent.isLocked())
299                {
300                    lockedContent.unlock();
301                }
302                
303                ((RemovableAmetysObject) content).remove();
304            }
305            
306            parent.saveChanges();
307            
308            for (String id : contentIdsToDelete)
309            {
310                _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_DELETED, _currentUserProvider.getUser(), eventParams.get(id)));
311                
312                @SuppressWarnings("unchecked")
313                List<String> deletedContents = (List<String>) results.get("deleted-contents");
314                deletedContents.add(id);
315            }
316        }
317        finally 
318        {
319            _observationManager.removeArgumentForEvents(new String[] {ObservationConstants.EVENT_CONTENT_DELETED}, ObservationConstants.ARGS_CONTENT_COMMIT);
320            _commitAllChanges(logger);
321        }
322    }
323    
324    /**
325     * True if we can delete the content (check if removable, rights and if locked)
326     * @param content the content
327     * @param rights the map of rights id with its prefix
328     * @param results the results map
329     * @return true if we can delete the content
330     */
331    protected boolean _canDeleteContent(Content content, Map<String, String> rights, Map<String, Object> results)
332    {
333        if (!(content instanceof RemovableAmetysObject))
334        {
335            throw new IllegalArgumentException("The content [" + content.getId() + "] is not a RemovableAmetysObject, it can't be deleted.");
336        }
337        
338        if (!_hasRight(content, rights))
339        {
340            // User has no sufficient right
341            @SuppressWarnings("unchecked")
342            List<Content> norightContents = (List<Content>) results.get("unauthorized-contents");
343            norightContents.add(content);
344            
345            return false;
346        }
347        else if (_isLocked(content))
348        {
349            @SuppressWarnings("unchecked")
350            List<Content> lockedContents = (List<Content>) results.get("locked-contents");
351            lockedContents.add(content);
352            
353            return false;
354        }
355        
356        return true;
357    }
358    
359    /**
360     * Get parameters for content deleted {@link Event}
361     * @param content the removed content
362     * @return the event's parameters
363     */
364    protected Map<String, Object> _getEventParametersForDeletion (Content content)
365    {
366        Map<String, Object> eventParams = new HashMap<>();
367        eventParams.put(ObservationConstants.ARGS_CONTENT, content);
368        eventParams.put(ObservationConstants.ARGS_CONTENT_NAME, content.getName());
369        eventParams.put(ObservationConstants.ARGS_CONTENT_ID, content.getId());
370        return eventParams;
371    }
372    
373    /**
374     * Commit all changes in solr
375     * @param logger The logger
376     */
377    protected void _commitAllChanges(Logger logger)
378    {
379        // Before trying to commit, be sure all the async observers of the current request are finished
380        for (Future future : _observationManager.getFuturesForRequest())
381        {
382            try
383            {
384                future.get();
385            }
386            catch (ExecutionException | InterruptedException e)
387            {
388                logger.info("An exception occured when calling #get() on Future result of an observer." , e);
389            }
390        }
391        
392        // Commit all uncommited changes
393        try
394        {
395            _solrIndexer.commit();
396            
397            logger.info("Deleted contents are now committed into Solr.");
398        }
399        catch (IOException | SolrServerException e)
400        {
401            logger.error("Impossible to commit changes", e);
402        }
403    }
404    
405    /**
406     * Determines if the content is locked
407     * @param content the content
408     * @return true if the content is locked
409     */
410    protected boolean _isLocked(Content content)
411    {
412        return _smartHelper.isLocked(content);
413    }
414    
415    /**
416     * Determines if the user has sufficient right for the given content
417     * @param content the content
418     * @param rights the map of rights id with its prefix
419     * @return true if user has sufficient right
420     */
421    protected boolean _hasRight(Content content, Map<String, String> rights)
422    {
423        if (rights.isEmpty())
424        {
425            return true;
426        }
427        
428        return _smartHelper.hasRight(rights, content);
429    }
430    
431    /**
432     * True if the content is referenced
433     * @param content the content
434     * @param logger The logger
435     * @return true if the content is referenced
436     */
437    public abstract boolean isContentReferenced(Content content, Logger logger);
438
439    /**
440     * Check that deletion can be performed without blocking errors
441     * @param content The initial content to delete
442     * @param rights the map of rights id with its prefix
443     * @param results The results
444     * @param logger The logger
445     * @return true if the deletion can be performed
446     */
447    protected abstract boolean _checkBeforeDeletion(Content content, Map<String, String> rights, Map<String, Object> results, Logger logger);
448    
449    /**
450     * Remove relations
451     * @param content the content
452     * @param logger The logger
453     * @return <code>true</code> if all relations have been removed
454     */
455    protected abstract boolean _removeRelations(Content content, Logger logger);
456    
457    /**
458     * Get the id of children to be deleted. 
459     * All children shared with other contents which are not part of deletion, will be not deleted.
460     * @param content The content to delete
461     * @param rights the map of rights id with its prefix
462     * @param results The results
463     * @param logger The logger
464     * @return The id of contents to be deleted
465     */
466    protected abstract Set<String> _getContentIdsToDelete (Content content, Map<String, String> rights, Map<String, Object> results, Logger logger);
467    
468}