001/*
002 *  Copyright 2018 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.Arrays;
021import java.util.HashMap;
022import java.util.HashSet;
023import java.util.List;
024import java.util.Map;
025import java.util.Set;
026import java.util.concurrent.ExecutionException;
027import java.util.concurrent.Future;
028import java.util.stream.Collectors;
029
030import org.apache.avalon.framework.component.Component;
031import org.apache.avalon.framework.service.ServiceException;
032import org.apache.avalon.framework.service.ServiceManager;
033import org.apache.avalon.framework.service.Serviceable;
034import org.apache.commons.lang3.ArrayUtils;
035import org.apache.solr.client.solrj.SolrServerException;
036
037import org.ametys.cms.ObservationConstants;
038import org.ametys.cms.clientsideelement.content.SmartContentClientSideElementHelper;
039import org.ametys.cms.content.external.ExternalizableMetadataHelper;
040import org.ametys.cms.content.indexing.solr.SolrIndexer;
041import org.ametys.cms.repository.Content;
042import org.ametys.cms.repository.WorkflowAwareContent;
043import org.ametys.cms.workflow.ContentWorkflowHelper;
044import org.ametys.core.observation.Event;
045import org.ametys.core.observation.ObservationManager;
046import org.ametys.core.user.CurrentUserProvider;
047import org.ametys.plugins.repository.AmetysObjectResolver;
048import org.ametys.plugins.repository.AmetysRepositoryException;
049import org.ametys.plugins.repository.ModifiableAmetysObject;
050import org.ametys.plugins.repository.RemovableAmetysObject;
051import org.ametys.plugins.repository.lock.LockableAmetysObject;
052import org.ametys.plugins.repository.metadata.ModifiableCompositeMetadata;
053import org.ametys.runtime.plugin.component.AbstractLogEnabled;
054
055import com.opensymphony.workflow.WorkflowException;
056
057/**
058 * Delete orgunit component
059 */
060public class DeleteOrgUnitComponent extends AbstractLogEnabled implements Component, Serviceable
061{
062    /** The avalon role. */
063    public static final String ROLE = DeleteOrgUnitComponent.class.getName();
064   
065    private static final int _REMOVE_REFERENCE_DEFAULT_ACTION_ID = 22;
066    
067    /** The organisation chart page handler */
068    protected OrganisationChartPageHandler _oCPageHandler;
069    
070    /** The Content workflow helper */
071    protected ContentWorkflowHelper _contentWorkflowHelper;
072    
073    /** The Solr indexer. */
074    protected SolrIndexer _solrIndexer;
075    
076    /** The Ametys object resolver */
077    protected AmetysObjectResolver _resolver;
078    
079    /** The observation manager */
080    protected ObservationManager _observationManager;
081    
082    /** The current user provider */
083    protected CurrentUserProvider _currentUserProvider;
084    
085    /** Helper for smart content client elements */
086    protected SmartContentClientSideElementHelper _smartHelper;
087    
088    @Override
089    public void service(ServiceManager smanager) throws ServiceException
090    {
091        _oCPageHandler = (OrganisationChartPageHandler) smanager.lookup(OrganisationChartPageHandler.ROLE);
092        _contentWorkflowHelper = (ContentWorkflowHelper) smanager.lookup(ContentWorkflowHelper.ROLE);
093        _solrIndexer = (SolrIndexer) smanager.lookup(SolrIndexer.ROLE);
094        _resolver = (AmetysObjectResolver) smanager.lookup(AmetysObjectResolver.ROLE);
095        _observationManager = (ObservationManager) smanager.lookup(ObservationManager.ROLE);
096        _currentUserProvider = (CurrentUserProvider) smanager.lookup(CurrentUserProvider.ROLE);
097        _smartHelper = (SmartContentClientSideElementHelper) smanager.lookup(SmartContentClientSideElementHelper.ROLE);
098    }
099    
100    /**
101     * Delete orgUnits contents
102     * @param contentsId The ids of contents to delete
103     * @param parameters the additional parameters
104     * @param rights the map of rights id with its prefix
105     * @return the deleted and undeleted contents
106     */
107    public Map<String, Object> deleteContents(List<String> contentsId, Map<String, Object> parameters, Map<String, String> rights)
108    {
109        Map<String, Object> results = new HashMap<>();
110        
111        List<String> alreadyDeletedContentIds = new ArrayList<>();
112        for (String contentId : contentsId)
113        {
114            if (!alreadyDeletedContentIds.contains(contentId))
115            {
116                Content content = _resolver.resolveById(contentId);
117                
118                Map<String, Object> result = new HashMap<>();
119                result.put("deleted-contents", new ArrayList<String>());
120                result.put("undeleted-contents", new ArrayList<Content>());
121                result.put("referenced-contents", new ArrayList<Content>());
122                result.put("unauthorized-contents", new ArrayList<Content>());
123                result.put("locked-contents", new ArrayList<Content>());
124                result.put("initial-content", content.getId());
125                results.put(contentId, result);
126                
127                boolean referenced = _oCPageHandler.isReferencedOrgUnit(content);
128                if (referenced || !checkBeforeDeletion(content, result, rights))
129                {
130                    if (referenced)
131                    {
132                        // Indicate that the content is referenced.
133                        @SuppressWarnings("unchecked")
134                        List<Content> referencedContents = (List<Content>) result.get("referenced-contents");
135                        referencedContents.add(content);
136                    }
137                    result.put("check-before-deletion-failed", true);
138                }
139                else
140                {
141                    // Process deletion
142                    deleteContent(content, result, parameters, rights);
143
144                    @SuppressWarnings("unchecked")
145                    List<String> deletedContents = (List<String>) result.get("deleted-contents");
146                    if (deletedContents != null)
147                    {
148                        alreadyDeletedContentIds.addAll(deletedContents);
149                        
150                    }
151                }
152            }
153            else
154            {
155                getLogger().info("Content with id '{}' has been already deleted during its parent deletion", contentId);
156            }
157        }
158
159        return results;
160    }
161
162    /**
163     * Delete one content
164     * @param content the content to delete
165     * @param results the results map
166     * @param rights the map of rights id with its prefix
167     * @param parameters the additional parameters
168     */
169    protected void deleteContent(Content content, Map<String, Object> results, Map<String, Object> parameters, Map<String, String> rights)
170    {
171        // 1 - First delete relation to parent
172        Content parentOrgUnit = _oCPageHandler.getParentContent(content);
173        boolean success = true;
174        if (parentOrgUnit != null)
175        {
176            success = _removeRelation((WorkflowAwareContent) parentOrgUnit, content, OrganisationChartPageHandler.METADATA_CHILD_ORGUNIT, _REMOVE_REFERENCE_DEFAULT_ACTION_ID, results);
177        }
178        
179        // 2 - If succeed, process to deletion
180        if (success)
181        {
182            deleteOrgUnit(content, results, rights);
183        }
184        else
185        {
186            @SuppressWarnings("unchecked")
187            List<Content> undeletedContents = (List<Content>) results.get("undeleted-contents");
188            undeletedContents.add(content);
189        }
190    }
191    
192    /**
193     * Delete one orgUnit
194     * @param orgUnit the orgUnit to delete
195     * @param rights the map of rights id with its prefix
196     * @param results the results map
197     */
198    @SuppressWarnings("unchecked")
199    protected void deleteOrgUnit(Content orgUnit, Map<String, Object> results, Map<String, String> rights)
200    {
201        Set<String> toDelete = _getChildrenIdToDelete(orgUnit, results, rights);
202        
203        List<Content> referencedContents = (List<Content>) results.get("referenced-contents");
204        List<Content> lockedContents = (List<Content>) results.get("locked-contents");
205        List<Content> unauthorizedContents = (List<Content>) results.get("unauthorized-contents");
206        
207        if (referencedContents.size() == 0 && lockedContents.size() == 0 && unauthorizedContents.size() == 0)
208        {
209            _finalizeDeleteContents(toDelete, orgUnit.getParent(), results);
210        }
211    }
212    
213    /**
214     * Get the id of children to be deleted. 
215     * All children shared with other contents which are not part of deletion, will be not deleted.
216     * @param orgUnit The orgunit to delete
217     * @param results The results
218     * @param rights the map of rights id with its prefix
219     * @return The id of contents to be deleted
220     */
221    protected Set<String> _getChildrenIdToDelete (Content orgUnit, Map<String, Object> results, Map<String, String> rights)
222    {
223        Set<String> toDelete = new HashSet<>();
224        
225        if (_canDeleteContent(orgUnit, results, rights))
226        {
227            toDelete.add(orgUnit.getId());
228            
229            for (Content childOrgUnit : _oCPageHandler.getChildContents(orgUnit))
230            {
231                if (!_oCPageHandler.isReferencedOrgUnit(orgUnit))
232                {
233                    toDelete.addAll(_getChildrenIdToDelete(childOrgUnit, results, rights));
234                }
235                else
236                {
237                    // The child program item can not be deleted, remove the relation to the parent and stop iteration
238                    @SuppressWarnings("unchecked")
239                    List<Content> referencedContents = (List<Content>) results.get("referenced-contents");
240                    referencedContents.add(childOrgUnit);
241                }
242            }
243        }
244        
245        return toDelete;
246    }
247
248    /**
249     * Finalize the deletion of contents. Call observers and remove contents
250     * @param contentIdsToDelete the list of content id to delete
251     * @param parent the jcr parent for saving changes
252     * @param results the results map
253     */
254    private void _finalizeDeleteContents(Set<String> contentIdsToDelete, ModifiableAmetysObject parent, Map<String, Object> results)
255    {
256        @SuppressWarnings("unchecked")
257        List<Content> unauthorizedContents = (List<Content>) results.get("unauthorized-contents");
258        @SuppressWarnings("unchecked")
259        List<Content> lockedContents = (List<Content>) results.get("locked-contents");
260        
261        if (!unauthorizedContents.isEmpty() || !lockedContents.isEmpty())
262        {
263            //Do Nothing
264            return;
265        }
266        
267        try
268        {
269            _observationManager.addArgumentForEvents(new String[] {ObservationConstants.EVENT_CONTENT_DELETED}, ObservationConstants.ARGS_CONTENT_COMMIT, false);
270            
271            Map<String, Map<String, Object>> eventParams = new HashMap<>();
272            for (String id : contentIdsToDelete)
273            {
274                Content content = _resolver.resolveById(id);
275                Map<String, Object> eventParam = _getEventParametersForDeletion(content);
276                eventParams.put(id, eventParam);
277                
278                _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_DELETING, _currentUserProvider.getUser(), eventParam));
279                
280                // Remove the content.
281                LockableAmetysObject lockedContent = (LockableAmetysObject) content;
282                if (lockedContent.isLocked())
283                {
284                    lockedContent.unlock();
285                }
286                
287                ((RemovableAmetysObject) content).remove();
288            }
289            
290            
291            parent.saveChanges();
292            
293            for (String id : contentIdsToDelete)
294            {
295                _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_DELETED, _currentUserProvider.getUser(), eventParams.get(id)));
296                
297                @SuppressWarnings("unchecked")
298                List<String> deletedContents = (List<String>) results.get("deleted-contents");
299                deletedContents.add(id);
300            }
301        }
302        finally 
303        {
304            _observationManager.removeArgumentForEvents(new String[] {ObservationConstants.EVENT_CONTENT_DELETED}, ObservationConstants.ARGS_CONTENT_COMMIT);
305            _commitAllChanges();
306        }
307    }
308    
309    /**
310     * Check that deletion can be performed without blocking errors
311     * @param content The initial content to delete
312     * @param results The results
313     * @param rights the map of rights id with its prefix
314     * @return true if the deletion can be performed
315     */
316    protected boolean checkBeforeDeletion(Content content, Map<String, Object> results, Map<String, String> rights)
317    {
318        // Check right and lock on content it self
319        boolean allRight = _canDeleteContent(content, results, rights);
320        
321        // Check lock on parent contents
322        allRight = _checkParentBeforeDeletion(content, results) && allRight;
323        
324        // Check right and lock on children to be deleted or modified
325        allRight = _checkChildrenBeforeDeletion(content, results, rights) && allRight;
326        
327        return allRight;
328    }
329    
330    /**
331     * True if we can delete the content (check if removable, rights and if locked)
332     * @param content the content
333     * @param results the results map
334     * @param rights the map of rights id with its prefix
335     * @return true if we can delete the content
336     */
337    protected boolean _canDeleteContent(Content content, Map<String, Object> results, Map<String, String> rights)
338    {
339        if (!(content instanceof RemovableAmetysObject))
340        {
341            throw new IllegalArgumentException("The content [" + content.getId() + "] is not a RemovableAmetysObject, it can't be deleted.");
342        }
343        
344        if (!_hasRight(content, rights))
345        {
346            // User has no sufficient right
347            @SuppressWarnings("unchecked")
348            List<Content> norightContents = (List<Content>) results.get("unauthorized-contents");
349            norightContents.add(content);
350            
351            return false;
352        }
353        else if (_isLocked(content))
354        {
355            @SuppressWarnings("unchecked")
356            List<Content> lockedContents = (List<Content>) results.get("locked-contents");
357            lockedContents.add(content);
358            
359            return false;
360        }
361        
362        return true;
363    }
364    
365    /**
366     * True if the parent content is not locked
367     * @param content the content
368     * @param results the results map
369     * @return true if the parent content is not locked
370     */
371    protected boolean _checkParentBeforeDeletion(Content content,  Map<String, Object> results)
372    {
373        boolean allRight = true;
374        
375        // Check if parents are not locked
376        Content parentContent = _oCPageHandler.getParentContent(content);
377        if (_isLocked(parentContent))
378        {
379            @SuppressWarnings("unchecked")
380            List<Content> lockedContents = (List<Content>) results.get("locked-contents");
381            lockedContents.add(parentContent);
382            
383            allRight = false;
384        }
385        
386        return allRight;
387    }
388    
389    /**
390     * Browse children to check if deletion could succeed
391     * @param contentToCheck The current content to check
392     * @param results The result
393     * @param rights the map of rights id with its prefix
394     * @return true if the deletion can be processed
395     */
396    private boolean _checkChildrenBeforeDeletion(Content contentToCheck, Map<String, Object> results, Map<String, String> rights)
397    {
398        boolean allRight = true;
399        
400        List<Content> childOrgUnits = _oCPageHandler.getChildContents(contentToCheck);
401        for (Content childOrgUnit : childOrgUnits)
402        {
403            if (!_canDeleteContent(childOrgUnit, results, rights))
404            {
405                allRight = false;
406            }
407            else if (_oCPageHandler.isReferencedOrgUnit(childOrgUnit))
408            {
409                @SuppressWarnings("unchecked")
410                List<Content> referencedContents = (List<Content>) results.get("referenced-contents");
411                referencedContents.add(childOrgUnit);
412                
413                allRight = false;
414            }
415            else
416            {
417                // Browse children recursively
418                allRight = _checkChildrenBeforeDeletion(childOrgUnit, results, rights) && allRight;
419            }
420        }
421        
422        return allRight;
423    }
424    
425    /**
426     * Remove the relation parent-child relation on content. 
427     * @param contentToEdit The content to modified
428     * @param refContentToRemove The referenced content to be removed from content
429     * @param metadataName The name of metadata holding the child or parent relationship
430     * @param actionId The id of workflow action to edit the relation
431     * @param results the results map
432     * @return boolean true if remove relation successfully
433     */
434    protected boolean _removeRelation(WorkflowAwareContent contentToEdit, Content refContentToRemove, String metadataName, int actionId, Map<String, Object> results)
435    {
436        try
437        {
438            ModifiableCompositeMetadata metadataHolder = contentToEdit.getMetadataHolder();
439
440            String[] values = metadataHolder.getStringArray(metadataName, new String[0]);
441            
442            if (ArrayUtils.contains(values, refContentToRemove.getId()))
443            {
444                String[] newValues = ArrayUtils.removeElement(values, refContentToRemove.getId());
445                
446                List<Content> newContents = Arrays.asList(newValues).stream()
447                        .map(id -> (Content) _resolver.resolveById(id))
448                        .collect(Collectors.toList());
449                
450                ExternalizableMetadataHelper.setMetadata(metadataHolder, metadataName, newContents.toArray(new Content[newContents.size()]));
451                _applyChanges(contentToEdit, actionId);
452            }
453            
454            return true;
455        }
456        catch (WorkflowException | AmetysRepositoryException e)
457        {
458            getLogger().error("Unable to remove relationship to content {} ({}) on content {} ({}) for metadata {}", refContentToRemove.getTitle(), refContentToRemove.getId(), contentToEdit.getTitle(), contentToEdit.getId(), metadataName, e);
459            return false;
460        }
461    }
462    
463    private void _applyChanges(WorkflowAwareContent content, int actionId) throws WorkflowException
464    {
465        // Notify listeners
466        Map<String, Object> eventParams = new HashMap<>();
467        eventParams.put(ObservationConstants.ARGS_CONTENT, content);
468        eventParams.put(ObservationConstants.ARGS_CONTENT_ID, content.getId());
469        _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_MODIFIED, _currentUserProvider.getUser(), eventParams));
470       
471        _contentWorkflowHelper.doAction(content, actionId);
472    }
473    
474    /**
475     * Get parameters for content deleted {@link Event}
476     * @param content the removed content
477     * @return the event's parameters
478     */
479    protected Map<String, Object> _getEventParametersForDeletion (Content content)
480    {
481        Map<String, Object> eventParams = new HashMap<>();
482        eventParams.put(ObservationConstants.ARGS_CONTENT, content);
483        eventParams.put(ObservationConstants.ARGS_CONTENT_NAME, content.getName());
484        eventParams.put(ObservationConstants.ARGS_CONTENT_ID, content.getId());
485        return eventParams;
486    }
487    
488    /**
489     * Commit all changes in solr
490     */
491    protected void _commitAllChanges()
492    {
493        // Before trying to commit, be sure all the async observers of the current request are finished
494        for (Future future : _observationManager.getFuturesForRequest())
495        {
496            try
497            {
498                future.get();
499            }
500            catch (ExecutionException | InterruptedException e)
501            {
502                getLogger().info("An exception occured when calling #get() on Future result of an observer." , e);
503            }
504        }
505        
506        // Commit all uncommited changes
507        try
508        {
509            _solrIndexer.commit();
510            
511            if (getLogger().isDebugEnabled())
512            {
513                getLogger().info("Deleted contents are now committed into Solr.");
514            }
515        }
516        catch (IOException | SolrServerException e)
517        {
518            getLogger().error("Impossible to commit changes", e);
519        }
520    }
521    
522    /**
523     * Determines if the content is locked
524     * @param content the content
525     * @return true if the content is locked
526     */
527    protected boolean _isLocked(Content content)
528    {
529        return _smartHelper.isLocked(content);
530    }
531    
532    /**
533     * Determines if the user has sufficient right for the given content
534     * @param content the content
535     * @param rights the map of rights id with its prefix
536     * @return true if user has sufficient right
537     */
538    protected boolean _hasRight(Content content, Map<String, String> rights)
539    {
540        if (rights.isEmpty())
541        {
542            return true;
543        }
544        
545        return _smartHelper.hasRight(rights, content);
546    }
547}