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