001/*
002 *  Copyright 2017 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.odf.workflow.copy;
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.service.ServiceException;
031import org.apache.avalon.framework.service.ServiceManager;
032import org.apache.commons.lang.StringUtils;
033import org.apache.commons.lang3.ArrayUtils;
034import org.apache.solr.client.solrj.SolrServerException;
035
036import org.ametys.cms.ObservationConstants;
037import org.ametys.cms.clientsideelement.SmartContentClientSideElement;
038import org.ametys.cms.content.external.ExternalizableMetadataHelper;
039import org.ametys.cms.content.indexing.solr.SolrIndexer;
040import org.ametys.cms.repository.Content;
041import org.ametys.cms.repository.WorkflowAwareContent;
042import org.ametys.cms.workflow.ContentWorkflowHelper;
043import org.ametys.cms.workflow.copy.CopyContentClientInteraction;
044import org.ametys.core.observation.Event;
045import org.ametys.core.observation.ObservationManager;
046import org.ametys.core.ui.Callable;
047import org.ametys.core.util.JSONUtils;
048import org.ametys.odf.ODFHelper;
049import org.ametys.odf.ProgramItem;
050import org.ametys.odf.course.Course;
051import org.ametys.odf.courselist.CourseList;
052import org.ametys.odf.courselist.CourseListContainer;
053import org.ametys.odf.program.Program;
054import org.ametys.odf.program.ProgramPart;
055import org.ametys.odf.program.TraversableProgramPart;
056import org.ametys.plugins.repository.AmetysRepositoryException;
057import org.ametys.plugins.repository.metadata.ModifiableCompositeMetadata;
058import org.ametys.runtime.i18n.I18nizableText;
059
060import com.opensymphony.workflow.WorkflowException;
061
062/**
063 * Client side element for ODF content copy
064 * 
065 */
066public class CopyODFContentClientSideElement extends SmartContentClientSideElement
067{
068    /** The key to get the duplication mode */
069    public static final String DUPLICATION_MODE_KEY = "$duplicationMode";
070    
071    /** The key to tell if we keep the creation title */
072    public static final String KEEP_CREATION_TITLE_KEY = "$keepCreationTitle";
073    
074    /** The content workflow helper */
075    protected ContentWorkflowHelper _contentWorkflowHelper;
076    /** The observation manager */
077    protected ObservationManager _observationManager;
078    /** The solr indexer */
079    protected SolrIndexer _solrIndexer;
080    /** The ODF helper */
081    protected ODFHelper _odfHelper;
082    /** The client side element for copy */
083    protected CopyContentClientInteraction _copyClientSideInteraction;
084    /** JSON utils */
085    protected JSONUtils _jsonUtils;
086
087    @Override
088    public void service(ServiceManager sManager) throws ServiceException
089    {
090        super.service(sManager);
091        _contentWorkflowHelper = (ContentWorkflowHelper) sManager.lookup(ContentWorkflowHelper.ROLE);
092        _observationManager = (ObservationManager) sManager.lookup(ObservationManager.ROLE);
093        _solrIndexer = (SolrIndexer) sManager.lookup(SolrIndexer.ROLE);
094        _odfHelper = (ODFHelper) sManager.lookup(ODFHelper.ROLE);
095        _copyClientSideInteraction = (CopyContentClientInteraction) sManager.lookup(CopyContentClientInteraction.class.getName());
096        _jsonUtils = (JSONUtils) sManager.lookup(JSONUtils.ROLE);
097    }
098    
099    /**
100     * Determines if a ODF content can be copied and linked to a target content
101     * @param copiedContentId The id of copied content
102     * @param targetContentId The id of target content
103     * @param contextualParameters the contextual parameters
104     * @return the result with success to true if copy is available
105     */
106    @Callable
107    public Map<String, Object> canCopyTo(String copiedContentId, String targetContentId, Map<String, Object> contextualParameters)
108    {
109        Content copiedContent = _resolver.resolveById(copiedContentId);
110        Content targetContent = _resolver.resolveById(targetContentId);
111           
112        Map<String, Object> result = new HashMap<>();
113        
114        List<I18nizableText> errors = new ArrayList<>();
115        if (!_odfHelper.isRelationCompatible(copiedContent, targetContent, errors, contextualParameters))
116        {
117            // Invalid target
118            result.put("errorMessages", errors);
119            result.put("success", false); 
120        }
121        else
122        {
123            result.put("success", true); 
124        }
125        
126        return result;
127    }
128    
129    /**
130     * Creates a content by copy of another one.<br>
131     * Also handle the possible inner duplication depending on the duplication mode for each metadata of type "content". 
132     * @param baseContentId The id of content to copy
133     * @param newContentTitle The title of content to create
134     * @param metadataSetNameToCopy The metadata set name to copy. Can be null
135     * @param metadataSetTypeToCopy The metadata set type to copy. Can be null
136     * @param initActionId The init workflow action id for copy
137     * @param editActionId The workflow action for editing content
138     * @param duplicationModeAsString the duplication mode
139     * @param parentContentId the parent id under which the duplicated content will be created. Can be null
140     * @return the copy result
141     * @throws Exception if an error occurred during copy
142     */
143    @Callable
144    public Map<String, Object> createContentByCopy(String baseContentId, String newContentTitle, String metadataSetNameToCopy, String metadataSetTypeToCopy, int initActionId, int editActionId, String duplicationModeAsString, String parentContentId) throws Exception
145    {
146        Map<String, Object> result = new HashMap<>();
147        result.put("locked-contents", new ArrayList<Map<String, Object>>());
148
149        String [] handledEventIds = new String[] {ObservationConstants.EVENT_CONTENT_ADDED, ObservationConstants.EVENT_CONTENT_MODIFIED,  ObservationConstants.EVENT_CONTENT_WORKFLOW_CHANGED};
150        try
151        {
152            _observationManager.addArgumentForEvents(handledEventIds, ObservationConstants.ARGS_CONTENT_COMMIT, false);
153            
154            DuplicationMode duplicationMode = StringUtils.isNotBlank(duplicationModeAsString) ? DuplicationMode.valueOf(duplicationModeAsString.toUpperCase()) : DuplicationMode.SINGLE;
155            
156            if (!checkBeforeDuplication(baseContentId, parentContentId, duplicationMode, result))
157            {
158                result.put("check-before-duplication-failed", true);
159            }
160            else
161            {
162                Map<String, Object> copyMap = new HashMap<>();
163                copyMap.put(DUPLICATION_MODE_KEY, duplicationMode.toString());
164                copyMap.put(KEEP_CREATION_TITLE_KEY, true);
165                
166                String jsonMap = _jsonUtils.convertObjectToJson(copyMap);
167                result = _copyClientSideInteraction.createContentByCopy(baseContentId, newContentTitle, jsonMap, metadataSetNameToCopy, metadataSetTypeToCopy, initActionId, editActionId);
168                
169                if (StringUtils.isNotBlank(parentContentId) && result.containsKey("mainContentId"))
170                {
171                    String mainContentId = (String) result.get("mainContentId");
172                    _linkCopiedContentToParent(mainContentId, parentContentId);
173                }
174            }
175        }
176        finally 
177        {
178            _observationManager.removeArgumentForEvents(handledEventIds, ObservationConstants.ARGS_CONTENT_COMMIT);
179            _commitAllChanges();
180        }
181        
182        return result;
183    }
184    
185    /**
186     * Check that duplication can be performed without blocking errors
187     * @param programItemId The program item id to copy
188     * @param parentContentId The parent content id
189     * @param duplicationMode The duplication mode
190     * @param results the results map
191     * @return true if the duplication can be performed
192     */
193    protected boolean checkBeforeDuplication(String programItemId, String parentContentId, DuplicationMode duplicationMode, Map<String, Object> results)
194    {
195        boolean allRight = true;
196        if (StringUtils.isNotBlank(parentContentId))
197        {
198            // Check if the parent is locked
199            Content parentContent = _resolver.resolveById(parentContentId);
200            if (_isLocked(parentContent))
201            {
202                @SuppressWarnings("unchecked")
203                List<Map<String, Object>> lockedContents = (List<Map<String, Object>>) results.get("locked-contents");
204                Map<String, Object> contentParams = getContentDefaultParameters (parentContent);
205                contentParams.put("description", _getLockedDescription(parentContent));
206                lockedContents.add(contentParams);
207            }
208        }
209            
210        ProgramItem programItem = _resolver.resolveById(programItemId);
211        if (duplicationMode == DuplicationMode.SINGLE)
212        {
213            // Check if the child are locked
214            for (ProgramItem programItemChild : _odfHelper.getChildProgramItems(programItem))
215            {
216                if (_isLocked((Content) programItemChild))
217                {
218                    @SuppressWarnings("unchecked")
219                    List<Map<String, Object>> lockedContents = (List<Map<String, Object>>) results.get("locked-contents");
220                    Map<String, Object> contentParams = getContentDefaultParameters ((Content) programItemChild);
221                    contentParams.put("description", _getLockedDescription((Content) programItemChild));
222                    lockedContents.add(contentParams);
223                    
224                    allRight = false;
225                }
226            }
227        }
228        else if (duplicationMode == DuplicationMode.STRUCTURE_ONLY)
229        {
230            // Check if course child are locked
231            for (Course course : _getCourse(programItem))
232            {
233                if (_isLocked(course))
234                {
235                    @SuppressWarnings("unchecked")
236                    List<Map<String, Object>> lockedContents = (List<Map<String, Object>>) results.get("locked-contents");
237                    Map<String, Object> contentParams = getContentDefaultParameters (course);
238                    contentParams.put("description", _getLockedDescription(course));
239                    lockedContents.add(contentParams);
240                    
241                    allRight = false;
242                }
243            }
244        }
245        
246        return allRight;
247    }
248    
249    /**
250     * Get all first courses in sub item of the program item
251     * @param programItem the program item
252     * @return a set of courses
253     */
254    protected Set<Course> _getCourse(ProgramItem programItem)
255    {
256        Set<Course> courses = new HashSet<>();
257        for (ProgramItem programItemChild : _odfHelper.getChildProgramItems(programItem))
258        {
259            if (programItemChild instanceof Course)
260            {
261                courses.add((Course) programItemChild);
262            }
263            else
264            {
265                courses.addAll(_getCourse(programItemChild));
266            }
267        }
268        
269        return courses;
270    }
271
272    /**
273     * Check if we can add the copied content to the parent content and set the relation
274     * @param copiedContentId the copied content id
275     * @param parentContentId the parent content id
276     */
277    protected void _linkCopiedContentToParent(String copiedContentId, String parentContentId)
278    {
279        if (StringUtils.isNotBlank(parentContentId))
280        {
281            Content parentContent = _resolver.resolveById(parentContentId);
282            if (_isLocked(parentContent))
283            {
284                //The content will be copied with no parent
285                return;
286            }
287            Content copiedContent = _resolver.resolveById(copiedContentId);
288            
289            if (!(copiedContent instanceof Program))
290            {
291                if (copiedContent instanceof CourseList && parentContent instanceof CourseListContainer)
292                {
293                    if (parentContent instanceof Course)
294                    {
295                        _addRelation((WorkflowAwareContent) parentContent, (WorkflowAwareContent) copiedContent, Course.METADATA_CHILD_COURSE_LISTS, CourseList.METADATA_PARENT_COURSES, 22);
296                    }
297                    else
298                    {
299                        _addRelation((WorkflowAwareContent) parentContent, (WorkflowAwareContent) copiedContent, TraversableProgramPart.METADATA_CHILD_PROGRAM_PARTS, ProgramPart.METADATA_PARENT_PROGRAM_PARTS, 22);
300                    }
301                }
302                else if (copiedContent instanceof ProgramPart && parentContent instanceof ProgramPart)
303                {
304                    _addRelation((WorkflowAwareContent) parentContent, (WorkflowAwareContent) copiedContent, TraversableProgramPart.METADATA_CHILD_PROGRAM_PARTS, ProgramPart.METADATA_PARENT_PROGRAM_PARTS, 22);
305                    
306                }
307                else if (copiedContent instanceof Course && parentContent instanceof CourseList)
308                {
309                    _addRelation((WorkflowAwareContent) parentContent, (WorkflowAwareContent) copiedContent, CourseList.METADATA_CHILD_COURSES, Course.METADATA_PARENT_COURSE_LISTS, 22);
310                    
311                }
312            }
313        }
314    }
315    
316    /**
317     * Add the relation parent-child relation on content. 
318     * @param parentContent The parent content
319     * @param childContent The child content to be added from parentContent
320     * @param parentMetadataName The name of the parent metadata holding the child relationship
321     * @param childMetadataName The name of the child metadata holding the parent relationship
322     * @param actionId The id of workflow action to edit the relation
323     * @return boolean true if add relation successfully
324     */
325    protected boolean _addRelation(WorkflowAwareContent parentContent, WorkflowAwareContent childContent, String parentMetadataName, String childMetadataName, int actionId)
326    {
327        try
328        {
329            setJCRReference(parentContent, childContent, parentMetadataName);
330            setJCRReference(childContent, parentContent, childMetadataName);
331
332            _applyChanges(parentContent, actionId);
333            return true;
334        }
335        catch (WorkflowException | AmetysRepositoryException e)
336        {
337            getLogger().error("Unable to add relationship to content {} ({}) with content {} ({}) for metadatas {} and {}", parentContent.getTitle(), parentContent.getId(), childContent.getTitle(), childContent.getId(), parentMetadataName, childMetadataName, e);
338            return false;
339        }
340    }
341    
342    /**
343     * Add the jcr relation on content. 
344     * @param contentToEdit The content edit
345     * @param refContent The referenced content to be added
346     * @param metadataName The name of the metadata holding the relationship
347     */
348    protected void setJCRReference(WorkflowAwareContent contentToEdit, Content refContent, String metadataName)
349    {
350        ModifiableCompositeMetadata metadataHolder = contentToEdit.getMetadataHolder();
351        String[] values = metadataHolder.getStringArray(metadataName, new String[0]);
352        
353        if (!ArrayUtils.contains(values, refContent.getId()))
354        {
355            String[] newValues = ArrayUtils.add(values, refContent.getId());
356            
357            List<Content> newContents = Arrays.asList(newValues).stream()
358                    .map(id -> (Content) _resolver.resolveById(id))
359                    .collect(Collectors.toList());
360            
361            // Set Jcr content reference if we pass contents in arguments
362            ExternalizableMetadataHelper.setMetadata(metadataHolder, metadataName, newContents.toArray(new Content[newContents.size()]));
363        }
364    }
365    
366    /**
367     * Apply changes to the content
368     * @param content the content changed
369     * @param actionId the workflow action id
370     * @throws WorkflowException if an error occurred
371     */
372    protected void _applyChanges(WorkflowAwareContent content, int actionId) throws WorkflowException
373    {
374        // Notify listeners
375        Map<String, Object> eventParams = new HashMap<>();
376        eventParams.put(ObservationConstants.ARGS_CONTENT, content);
377        _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_MODIFIED, _currentUserProvider.getUser(), eventParams));
378       
379        _contentWorkflowHelper.doAction(content, actionId);
380    }
381    
382    /**
383     * Commit all changes in solr
384     */
385    protected void _commitAllChanges()
386    {
387        // Before trying to commit, be sure all the async observers of the current request are finished
388        for (Future future : _observationManager.getFuturesForRequest())
389        {
390            try
391            {
392                future.get();
393            }
394            catch (ExecutionException | InterruptedException e)
395            {
396                getLogger().info("An exception occured when calling #get() on Future result of an observer." , e);
397            }
398        }
399        
400        // Commit all uncommited changes
401        try
402        {
403            _solrIndexer.commit();
404            
405            getLogger().debug("Deleted contents are now committed into Solr.");
406        }
407        catch (IOException | SolrServerException e)
408        {
409            getLogger().error("Impossible to commit changes", e);
410        }
411    }
412    
413    /**
414     * Enumeration for the mode of duplication
415     */
416    public enum DuplicationMode
417    {
418        /** Duplicate the content only */
419        SINGLE,
420        /** Duplicate the content and its structure */
421        STRUCTURE_ONLY,
422        /** Duplicate the content and its structure and its courses */
423        FULL
424    }
425}