001/*
002 *  Copyright 2015 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.cms.repository;
017
018import java.time.ZonedDateTime;
019import java.util.ArrayList;
020import java.util.Date;
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;
028
029import javax.jcr.Node;
030import javax.jcr.RepositoryException;
031import javax.jcr.Session;
032
033import org.apache.avalon.framework.component.Component;
034import org.apache.avalon.framework.context.Context;
035import org.apache.avalon.framework.context.ContextException;
036import org.apache.avalon.framework.context.Contextualizable;
037import org.apache.avalon.framework.logger.AbstractLogEnabled;
038import org.apache.avalon.framework.service.ServiceException;
039import org.apache.avalon.framework.service.ServiceManager;
040import org.apache.avalon.framework.service.Serviceable;
041import org.apache.cocoon.components.ContextHelper;
042import org.apache.cocoon.environment.Request;
043import org.apache.commons.lang3.StringUtils;
044import org.apache.commons.lang3.tuple.Pair;
045
046import org.ametys.cms.ObservationConstants;
047import org.ametys.cms.content.ContentHelper;
048import org.ametys.cms.content.archive.ArchiveConstants;
049import org.ametys.cms.content.referencetable.HierarchicalReferenceTablesHelper;
050import org.ametys.cms.contenttype.ContentType;
051import org.ametys.cms.contenttype.ContentTypeExtensionPoint;
052import org.ametys.cms.contenttype.ContentTypesHelper;
053import org.ametys.cms.contenttype.MetadataSet;
054import org.ametys.cms.data.ContentDataHelper;
055import org.ametys.cms.data.type.ModelItemTypeConstants;
056import org.ametys.cms.lock.LockContentManager;
057import org.ametys.cms.repository.ReactionableObject.ReactionType;
058import org.ametys.cms.tag.CMSTag;
059import org.ametys.cms.tag.TagProviderExtensionPoint;
060import org.ametys.cms.workflow.AbstractContentWorkflowComponent;
061import org.ametys.cms.workflow.ContentWorkflowHelper;
062import org.ametys.cms.workflow.EditContentFunction;
063import org.ametys.core.observation.Event;
064import org.ametys.core.observation.ObservationManager;
065import org.ametys.core.right.RightManager;
066import org.ametys.core.right.RightManager.RightResult;
067import org.ametys.core.ui.Callable;
068import org.ametys.core.user.CurrentUserProvider;
069import org.ametys.core.user.UserIdentity;
070import org.ametys.core.user.UserManager;
071import org.ametys.core.util.DateUtils;
072import org.ametys.plugins.core.user.UserHelper;
073import org.ametys.plugins.explorer.ExplorerNode;
074import org.ametys.plugins.explorer.resources.ModifiableResourceCollection;
075import org.ametys.plugins.explorer.resources.Resource;
076import org.ametys.plugins.explorer.resources.ResourceCollection;
077import org.ametys.plugins.repository.AmetysObject;
078import org.ametys.plugins.repository.AmetysObjectIterable;
079import org.ametys.plugins.repository.AmetysObjectResolver;
080import org.ametys.plugins.repository.AmetysRepositoryException;
081import org.ametys.plugins.repository.CopiableAmetysObject;
082import org.ametys.plugins.repository.ModifiableAmetysObject;
083import org.ametys.plugins.repository.ModifiableTraversableAmetysObject;
084import org.ametys.plugins.repository.RemovableAmetysObject;
085import org.ametys.plugins.repository.TraversableAmetysObject;
086import org.ametys.plugins.repository.UnknownAmetysObjectException;
087import org.ametys.plugins.repository.data.holder.ModifiableModelLessDataHolder;
088import org.ametys.plugins.repository.lock.LockAwareAmetysObject;
089import org.ametys.plugins.repository.lock.LockHelper;
090import org.ametys.plugins.repository.lock.LockableAmetysObject;
091import org.ametys.plugins.repository.provider.RequestAttributeWorkspaceSelector;
092import org.ametys.plugins.repository.version.ModifiableDataAwareVersionableAmetysObject;
093import org.ametys.plugins.repository.version.VersionableAmetysObject;
094import org.ametys.plugins.workflow.AbstractWorkflowComponent;
095import org.ametys.plugins.workflow.component.CheckRightsCondition;
096import org.ametys.plugins.workflow.store.AbstractJackrabbitWorkflowStore;
097import org.ametys.plugins.workflow.store.AmetysObjectWorkflowStore;
098import org.ametys.plugins.workflow.support.WorkflowProvider;
099import org.ametys.plugins.workflow.support.WorkflowProvider.AmetysObjectWorkflow;
100import org.ametys.runtime.authentication.AccessDeniedException;
101
102import com.opensymphony.workflow.WorkflowException;
103import com.opensymphony.workflow.spi.Step;
104
105/**
106 * DAO for manipulating contents
107 *
108 */
109public class ContentDAO extends AbstractLogEnabled implements Serviceable, Component, Contextualizable
110{
111    /** Avalon Role */
112    public static final String ROLE = ContentDAO.class.getName();
113
114    /** Deletion status */
115    private static final String __CONTENT_DELETION_STATUS_DELETED = "deleted";
116    private static final String __CONTENT_DELETION_STATUS_UNDELETED = "undeleted";
117    private static final String __CONTENT_DELETION_STATUS_REFERENCED = "referenced";
118    private static final String __CONTENT_DELETION_STATUS_UNAUTHORIZED = "unauthorized";
119    private static final String __CONTENT_DELETION_STATUS_LOCKED = "locked";
120    
121    /** Ametys resolver */
122    protected AmetysObjectResolver _resolver;
123    /** Ametys observation manger */
124    protected ObservationManager _observationManager;
125    /** Component to get current user */
126    protected CurrentUserProvider _currentUserProvider;
127    /** Component to get tags */
128    protected TagProviderExtensionPoint _tagProvider;
129
130    /** Workflow component */
131    protected WorkflowProvider _workflowProvider;
132    /** Workflow helper component */
133    protected ContentWorkflowHelper _contentWorkflowHelper;
134    /** Component to manager lock */
135    protected LockContentManager _lockManager;
136    /** Content-type extension point */
137    protected ContentTypeExtensionPoint _contentTypeEP;
138    /** Content helper */
139    protected ContentHelper _contentHelper;
140    /** Content types helper */
141    protected ContentTypesHelper _cTypesHelper;
142    /** Rights manager */
143    protected RightManager _rightManager;
144    /** Cocoon context */
145    protected Context _context;
146    /** The user manager */
147    protected UserManager _usersManager;
148    /** Helper for users */
149    protected UserHelper _userHelper;
150    /** The helper component for hierarchical simple contents */
151    protected HierarchicalReferenceTablesHelper _hierarchicalSimpleContentsHelper;
152    /** The modifiable content helper */
153    protected ModifiableContentHelper _modifiableContentHelper;
154    
155    /** The mode for tag edition */
156    public enum TagMode 
157    {
158        /** Value will replace existing one */
159        REPLACE,
160        /** Value will be added to existing one */
161        INSERT,
162        /** Value will be removed from existing one */
163        REMOVE
164    }
165    
166    public void contextualize(Context context) throws ContextException
167    {
168        _context = context;
169    }
170    
171    @Override
172    public void service(ServiceManager smanager) throws ServiceException
173    {
174        _resolver = (AmetysObjectResolver) smanager.lookup(AmetysObjectResolver.ROLE);
175        _observationManager = (ObservationManager) smanager.lookup(ObservationManager.ROLE);
176        _currentUserProvider = (CurrentUserProvider) smanager.lookup(CurrentUserProvider.ROLE);
177        _usersManager = (UserManager) smanager.lookup(UserManager.ROLE);
178        _userHelper = (UserHelper) smanager.lookup(UserHelper.ROLE);
179        _tagProvider = (TagProviderExtensionPoint) smanager.lookup(TagProviderExtensionPoint.ROLE);
180        _workflowProvider = (WorkflowProvider) smanager.lookup(WorkflowProvider.ROLE);
181        _rightManager = (RightManager) smanager.lookup(RightManager.ROLE);
182        _contentTypeEP = (ContentTypeExtensionPoint) smanager.lookup(ContentTypeExtensionPoint.ROLE);
183        _lockManager = (LockContentManager) smanager.lookup(LockContentManager.ROLE);
184        _contentWorkflowHelper = (ContentWorkflowHelper) smanager.lookup(ContentWorkflowHelper.ROLE);
185        _cTypesHelper = (ContentTypesHelper) smanager.lookup(ContentTypesHelper.ROLE);
186        _contentHelper = (ContentHelper) smanager.lookup(ContentHelper.ROLE);
187        _modifiableContentHelper = (ModifiableContentHelper) smanager.lookup(ModifiableContentHelper.ROLE); 
188        _hierarchicalSimpleContentsHelper = (HierarchicalReferenceTablesHelper) smanager.lookup(HierarchicalReferenceTablesHelper.ROLE);
189    }
190
191    /**
192     * Delete contents and force the deletion of invert relations.
193     * @param contentsId The ids of contents to delete
194     * @return the deleted and undeleted contents
195     */
196    @Callable
197    public Map<String, Object> forceDeleteContents(List<String> contentsId)
198    {
199        Map<String, Object> results = _initializeResultsMap();
200        
201        for (String contentId : contentsId)
202        {
203            Content content = _resolver.resolveById(contentId);
204            Map<String, Object> contentParams = _transformContentToParams(content);
205            
206            String contentDeletionStatus = _getContentDeletionStatus(content, "CMS_Rights_DeleteContent");
207            
208            // The content is referenced, remove referencies
209            // Then remove referencies if you can
210            // Then check that the content is not referenced any more (should not happen)
211            if (contentDeletionStatus != null && contentDeletionStatus.equals(__CONTENT_DELETION_STATUS_REFERENCED) && _removeReferences(content) && !_isContentReferenced(content))
212            {
213                contentDeletionStatus = null;
214            }
215            
216            // The content has no constraints
217            if (contentDeletionStatus == null)
218            {
219                contentDeletionStatus = _reallyDeleteContent(content);
220            }
221            
222            String key = contentDeletionStatus + "-contents";
223            @SuppressWarnings("unchecked")
224            List<Map<String, Object>> statusedContents = (List<Map<String, Object>>) results.get(key);
225            statusedContents.add(contentParams);
226        }
227        
228        return results;
229    }
230    
231    private boolean _removeReferences(Content content)
232    {
233        int actionId = EditContentFunction.INVERT_EDIT_ACTION_ID;
234        
235        // Group references by referencer
236        Map<ModifiableDefaultContent, Set<String>> referencesByContent = new HashMap<>();
237        for (Pair<String, Content> referencingPair : _contentHelper.getReferencingContents(content))
238        {
239            Set<String> references = referencesByContent.computeIfAbsent((ModifiableDefaultContent) referencingPair.getValue(), __ -> new HashSet<>());
240            references.add(referencingPair.getKey());
241        }
242        
243        // Control that each referencer has the invert action available
244        for (ModifiableDefaultContent referencingContent : referencesByContent.keySet())
245        {
246            if (!_contentWorkflowHelper.isAvailableAction(referencingContent, actionId))
247            {
248                getLogger().warn("The action " + actionId + " is not available for the referencing content " + referencingContent.getId() + ". Impossible to delete the content " + content.getId() + ".");
249                return false;
250            }
251        }
252        
253        // Get the references
254        for (ModifiableDefaultContent referencingContent : referencesByContent.keySet())
255        {
256            // Break the references
257            for (String referencingPath : referencesByContent.get(referencingContent))
258            {
259                if (referencingContent.isMultiple(referencingPath))
260                {
261                    String[] values = ContentDataHelper.getContentIdsStreamFromMultipleContentData(referencingContent, referencingPath)
262                        .filter(id -> !id.equals(content.getId()))
263                        .toArray(String[]::new);
264                    referencingContent.setValue(referencingPath, values);
265                }
266                else
267                {
268                    referencingContent.removeValue(referencingPath);
269                }
270            }
271            
272            // edit
273            Map<String, Object> contextParameters = new HashMap<>();
274            contextParameters.put("quit", true);
275            
276            Map<String, Object> inputs = new HashMap<>();
277            inputs.put(AbstractWorkflowComponent.CONTEXT_PARAMETERS_KEY, contextParameters);
278            inputs.put(CheckRightsCondition.FORCE, true);
279            
280            try
281            {
282                _contentWorkflowHelper.doAction(referencingContent, EditContentFunction.INVERT_EDIT_ACTION_ID, inputs);
283            }
284            catch (WorkflowException e)
285            {
286                getLogger().error("An error occured while trying to update the workflow of the referencer " + referencingContent.toString(), e);
287            }
288        }
289        
290        return true;
291    }
292    
293    /**
294     * Delete contents
295     * @param contentsId The ids of contents to delete
296     * @return the deleted and undeleted contents
297     */
298    @Callable
299    public Map<String, Object> deleteContents(List<String> contentsId)
300    {
301        return deleteContents(contentsId, false);
302    }
303    
304    /**
305     * Delete contents
306     * @param contentsId The ids of contents to delete
307     * @param ignoreRights true to ignore user rights
308     * @return the deleted and undeleted contents
309     */
310    public Map<String, Object> deleteContents(List<String> contentsId, boolean ignoreRights)
311    {
312        return deleteContents(contentsId, ignoreRights ? null : "CMS_Rights_DeleteContent");
313    }
314    
315    /**
316     * Delete contents
317     * @param contentsId The ids of contents to delete
318     * @param deleteRightId The deletion right's id to check. Can be null to ignore rights
319     * @return the deleted and undeleted contents
320     */
321    public Map<String, Object> deleteContents(List<String> contentsId, String deleteRightId)
322    {
323        Map<String, Object> results = _initializeResultsMap();
324        
325        for (String contentId : contentsId)
326        {
327            Content content = _resolver.resolveById(contentId);
328            Map<String, Object> contentParams = _transformContentToParams(content);
329            
330            String contentDeletionStatus = _getContentDeletionStatus(content, deleteRightId);
331            
332            // The content has no constraints
333            if (contentDeletionStatus == null)
334            {
335                contentDeletionStatus = _reallyDeleteContent(content);
336            }
337            
338            String key = contentDeletionStatus + "-contents";
339            @SuppressWarnings("unchecked")
340            List<Map<String, Object>> statusedContents = (List<Map<String, Object>>) results.get(key);
341            statusedContents.add(contentParams);
342        }
343        
344        return results;
345    }
346    
347    private Map<String, Object> _initializeResultsMap()
348    {
349        Map<String, Object> results = new HashMap<>();
350
351        results.put(__CONTENT_DELETION_STATUS_DELETED + "-contents", new ArrayList<Map<String, Object>>());
352        results.put(__CONTENT_DELETION_STATUS_UNDELETED + "-contents", new ArrayList<Map<String, Object>>());
353        results.put(__CONTENT_DELETION_STATUS_REFERENCED + "-contents", new ArrayList<Map<String, Object>>());
354        results.put(__CONTENT_DELETION_STATUS_UNAUTHORIZED + "-contents", new ArrayList<Map<String, Object>>());
355        results.put(__CONTENT_DELETION_STATUS_LOCKED + "-contents", new ArrayList<Map<String, Object>>());
356        
357        return results;
358    }
359    
360    private String _reallyDeleteContent(Content content)
361    {
362        try
363        {
364            // All checks have been done, the content can be deleted
365            Map<String, Object> eventParams = _getEventParametersForDeletion(content);
366            
367            _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_DELETING, _currentUserProvider.getUser(), eventParams));
368            
369            RemovableAmetysObject removableContent = (RemovableAmetysObject) content;
370            ModifiableAmetysObject parent = removableContent.getParent();
371            
372            // Remove the content.
373            removableContent.remove();
374            
375            parent.saveChanges();
376            
377            _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_DELETED, _currentUserProvider.getUser(), eventParams));
378            
379            return __CONTENT_DELETION_STATUS_DELETED;
380        }
381        catch (AmetysRepositoryException e)
382        {
383            getLogger().error("Unable to delete content '" + content.getId() + "'", e);
384            
385            return __CONTENT_DELETION_STATUS_UNDELETED;
386        }
387    }
388    
389    private Map<String, Object> _transformContentToParams(Content content)
390    {
391        String contentName = content.getName();
392        String contentTitle = StringUtils.defaultString(_contentHelper.getTitle(content), contentName);
393        
394        Map<String, Object> contentParams = new HashMap<>();
395        contentParams.put("id", content.getId());
396        contentParams.put("title", contentTitle);
397        contentParams.put("name", contentName);
398        
399        return contentParams;
400    }
401    
402    private String _getContentDeletionStatus(Content content, String deleteRightId)
403    {
404        if (!(content instanceof RemovableAmetysObject))
405        {
406            throw new IllegalArgumentException("The content [" + content.getId() + "] is not a RemovableAmetysObject, it can't be deleted.");
407        }
408        
409        if (deleteRightId != null && !canDelete(content, deleteRightId))
410        {
411            // User has no sufficient right
412            return __CONTENT_DELETION_STATUS_UNAUTHORIZED;
413        }
414        
415        if (content instanceof LockableAmetysObject)
416        {
417            // If the content is locked, try to unlock it.
418            LockableAmetysObject lockableContent = (LockableAmetysObject) content;
419            if (lockableContent.isLocked())
420            {
421                boolean canUnlockAll = _rightManager.hasRight(_currentUserProvider.getUser(), "CMS_Rights_UnlockAll", "/cms") == RightResult.RIGHT_ALLOW;
422                if (LockHelper.isLockOwner(lockableContent, _currentUserProvider.getUser()) || canUnlockAll)
423                {
424                    lockableContent.unlock();
425                }
426                else
427                {
428                    return __CONTENT_DELETION_STATUS_LOCKED;
429                }
430            }
431        }
432
433        if (_isContentReferenced(content))
434        {
435            // Indicate that the content is referenced.
436            return __CONTENT_DELETION_STATUS_REFERENCED;
437        }
438        
439        return null;
440    }
441    
442    /**
443     * Test if content is still referenced before removing it
444     * @param content The content to remove
445     * @return true if content is still referenced
446     */
447    protected boolean _isContentReferenced (Content content)
448    {
449        return content.hasReferencingContents();
450    }
451    
452    /**
453     * Get parameters for content deleted {@link Event}
454     * @param content the removed content
455     * @return the event's parameters
456     */
457    protected Map<String, Object> _getEventParametersForDeletion (Content content)
458    {
459        Map<String, Object> eventParams = new HashMap<>();
460        eventParams.put(ObservationConstants.ARGS_CONTENT, content);
461        eventParams.put(ObservationConstants.ARGS_CONTENT_NAME, content.getName());
462        eventParams.put(ObservationConstants.ARGS_CONTENT_ID, content.getId());
463        return eventParams;
464    }
465    
466    /**
467     * Get the contents properties
468     * @param contentIds The ids of contents
469     * @param workspaceName The workspace name. Can be null to get contents in current workspace.
470     * @return The contents' properties
471     */
472    @Callable
473    public Map<String, Object> getContentsProperties (List<String> contentIds, String workspaceName)
474    {
475        Map<String, Object> result = new HashMap<>();
476        
477        List<Map<String, Object>> contents = new ArrayList<>();
478        List<String> contentsNotFound = new ArrayList<>();
479        
480        Request request = ContextHelper.getRequest(_context);
481        String currentWorkspace = RequestAttributeWorkspaceSelector.getForcedWorkspace(request);
482        try
483        {
484            if (StringUtils.isNotEmpty(workspaceName))
485            {
486                RequestAttributeWorkspaceSelector.setForcedWorkspace(request, workspaceName);
487            }
488            
489            for (String contentId : contentIds)
490            {
491                try
492                {
493                    Content content = _resolver.resolveById(contentId);
494                    contents.add(getContentProperties(content));
495                }
496                catch (UnknownAmetysObjectException e)
497                {
498                    contentsNotFound.add(contentId);
499                }
500            }
501            
502            result.put("contents", contents);
503            result.put("contentsNotFound", contentsNotFound);
504        }
505        finally
506        {
507            RequestAttributeWorkspaceSelector.setForcedWorkspace(request, currentWorkspace);
508        }
509        
510        return result;
511    }
512    
513    /**
514     * Get the content properties
515     * @param contentId The id of content
516     * @param workspaceName The workspace name. Can be null to get content in current workspace.
517     * @return The content's properties
518     */
519    @Callable
520    public Map<String, Object> getContentProperties (String contentId, String workspaceName)
521    {
522        Request request = ContextHelper.getRequest(_context);
523        String currentWorkspace = RequestAttributeWorkspaceSelector.getForcedWorkspace(request);
524        try
525        {
526            if (StringUtils.isNotEmpty(workspaceName))
527            {
528                RequestAttributeWorkspaceSelector.setForcedWorkspace(request, workspaceName);
529            }
530            
531            Content content = _resolver.resolveById(contentId);
532            return getContentProperties(content);
533        }
534        finally
535        {
536            RequestAttributeWorkspaceSelector.setForcedWorkspace(request, currentWorkspace);
537        }
538    }
539    
540    /**
541     * Get the content properties
542     * @param content The content
543     * @return The content properties
544     */
545    public Map<String, Object> getContentProperties (Content content)
546    {
547        Map<String, Object> infos = new HashMap<>();
548        
549        infos.put("id", content.getId());
550        infos.put("name", content.getName());
551        infos.put("title", _contentHelper.getTitle(content));
552        
553        if (ModelItemTypeConstants.MULTILINGUAL_STRING_ELEMENT_TYPE_ID.equals(content.getType(Content.ATTRIBUTE_TITLE).getId()))
554        {
555            infos.put("titleVariants", _contentHelper.getTitleVariants(content));
556        }
557        infos.put("path", content.getPath());
558        infos.put("types", content.getTypes());
559        infos.put("mixins", content.getMixinTypes());
560        String lang = content.getLanguage();
561        if (lang != null)
562        {
563            infos.put("lang", lang);
564        }
565        infos.put("creator", _userHelper.user2json(content.getCreator()));
566        infos.put("lastContributor", _userHelper.user2json(content.getLastContributor()));
567        infos.put("creationDate", DateUtils.dateToString(content.getCreationDate()));
568        infos.put("lastModified", DateUtils.dateToString(content.getLastModified()));
569        infos.put("isSimple", _contentHelper.isSimple(content));
570        infos.put("isReferenceTable", _contentHelper.isReferenceTable(content));
571        infos.put("parent", _hierarchicalSimpleContentsHelper.getParent(content));
572        
573        if (content instanceof WorkflowAwareContent)
574        {
575            WorkflowAwareContent waContent = (WorkflowAwareContent) content;
576            AmetysObjectWorkflow workflow = _workflowProvider.getAmetysObjectWorkflow(waContent);
577            
578            infos.put("workflowName", workflow.getWorkflowName(waContent.getWorkflowId()));
579            
580            List<Integer> workflowSteps = new ArrayList<>();
581            
582            List<Step> currentSteps = workflow.getCurrentSteps(waContent.getWorkflowId());
583            for (Step step : currentSteps)
584            {
585                workflowSteps.add(step.getStepId());
586            }
587            infos.put("workflowSteps", workflowSteps);
588            
589            int[] availableActions = _contentWorkflowHelper.getAvailableActions(waContent);
590            infos.put("availableActions", availableActions);
591        }
592        
593        if (content instanceof ModifiableContent)
594        {
595            infos.put("isModifiable", true);
596        }
597        
598        if (content instanceof LockAwareAmetysObject)
599        {
600            LockAwareAmetysObject lockableContent = (LockAwareAmetysObject) content;
601            if (lockableContent.isLocked())
602            {
603                infos.put("locked", true);
604                infos.put("lockOwner", _userHelper.user2json(lockableContent.getLockOwner()));
605                infos.put("canUnlock", _lockManager.canUnlock(lockableContent));
606            }
607        }
608        
609        infos.put("rights", getUserRights(content));
610        
611        Map<String, Object> additionalData = new HashMap<>();
612        
613        String[] contenttypes = content.getTypes();
614        for (String cTypeId : contenttypes)
615        {
616            ContentType cType = _contentTypeEP.getExtension(cTypeId);
617            if (cType != null)
618            {
619                additionalData.putAll(cType.getAdditionalData(content));
620            }
621        }
622        
623        if (!additionalData.isEmpty())
624        {
625            infos.put("additionalData", additionalData); 
626        }
627
628        infos.put("isTaggable", content instanceof TaggableAmetysObject);
629        
630        if (content instanceof ModifiableDataAwareVersionableAmetysObject)
631        {
632            ModifiableModelLessDataHolder unversionedDataHolder = ((ModifiableDataAwareVersionableAmetysObject) content).getUnversionedDataHolder();
633            if (unversionedDataHolder.hasValue(ArchiveConstants.META_ARCHIVE_SCHEDULED_DATE))
634            {
635                ZonedDateTime scheduledDate = unversionedDataHolder.getValue(ArchiveConstants.META_ARCHIVE_SCHEDULED_DATE);
636                infos.put("scheduledArchivingDate", DateUtils.zonedDateTimeToString(scheduledDate));
637            }
638        }
639        
640        if (content instanceof ReportableObject)
641        {
642            infos.put("reportsCount", ((ReportableObject) content).getReportsCount());
643        }
644
645        return infos;
646    }
647    
648    /**
649     * Get the content's properties for description
650     * @param contentId The id of content
651     * @param workspaceName The workspace name. Can be null to get content in current workspace.
652     * @return The content's properties for description
653     */
654    @Callable
655    public Map<String, Object> getContentDescription (String contentId, String workspaceName)
656    {
657        Request request = ContextHelper.getRequest(_context);
658        String currentWorkspace = RequestAttributeWorkspaceSelector.getForcedWorkspace(request);
659        try
660        {
661            if (StringUtils.isNotEmpty(workspaceName))
662            {
663                RequestAttributeWorkspaceSelector.setForcedWorkspace(request, workspaceName);
664            }
665            
666            Content content = _resolver.resolveById(contentId);
667            return getContentDescription(content);
668        }
669        finally
670        {
671            RequestAttributeWorkspaceSelector.setForcedWorkspace(request, currentWorkspace);
672        }
673    }
674    
675    /**
676     *Get the content's properties for description
677     * @param content The content
678     * @return The content's properties for description
679     */
680    public Map<String, Object> getContentDescription (Content content)
681    {
682        Map<String, Object> infos = new HashMap<>();
683        
684        infos.put("id", content.getId());
685        infos.put("name", content.getName());
686        infos.put("title", _contentHelper.getTitle(content));
687        infos.put("types", content.getTypes());
688        infos.put("mixins", content.getMixinTypes());
689        infos.put("lang", content.getLanguage());
690        infos.put("creator", _userHelper.user2json(content.getCreator()));
691        infos.put("lastContributor", _userHelper.user2json(content.getLastContributor()));
692        infos.put("lastModified", DateUtils.dateToString(content.getLastModified()));
693        infos.put("iconGlyph", _cTypesHelper.getIconGlyph(content));
694        infos.put("iconDecorator", _cTypesHelper.getIconDecorator(content));
695        infos.put("smallIcon", _cTypesHelper.getSmallIcon(content));
696        infos.put("mediumIcon", _cTypesHelper.getMediumIcon(content));
697        infos.put("largeIcon", _cTypesHelper.getLargeIcon(content));
698        
699        return infos;
700    }
701    
702    /**
703     * Get the metadata sets of a content
704     * @param contentId the content's id
705     * @param edition Set to true to get edition metadata set. False otherwise.
706     * @param includeInternal Set to true to include internal metadata sets.
707     * @return the metadata sets
708     */
709    @Callable
710    public List<Map<String, Object>> getContentMetadataSets (String contentId, boolean edition, boolean includeInternal)
711    {
712        List<Map<String, Object>> metadataSets = new ArrayList<>();
713        
714        Content content = _resolver.resolveById(contentId);
715        String contentTypeId = _cTypesHelper.getContentTypeIdForRendering(content);
716        
717        ContentType cType = _contentTypeEP.getExtension(contentTypeId);
718        
719        Set<String> metadataSetNames = edition ? cType.getEditionMetadataSetNames(includeInternal) : cType.getViewMetadataSetNames(includeInternal);
720        for (String metadataSetName : metadataSetNames)
721        {
722            MetadataSet metadataSet = edition ? cType.getMetadataSetForEdition(metadataSetName) : cType.getMetadataSetForView(metadataSetName);
723            
724            Map<String, Object> viewInfos = new HashMap<>();
725            viewInfos.put("name", metadataSetName);
726            viewInfos.put("label", metadataSet.getLabel());
727            viewInfos.put("description", metadataSet.getDescription());
728            metadataSets.add(viewInfos);
729        }
730        
731        return metadataSets;
732    }
733    
734    /**
735     * Get the user rights on content
736     * @param content The content
737     * @return The user's rights
738     */
739    protected Set<String> getUserRights (Content content)
740    {
741        UserIdentity user = _currentUserProvider.getUser();
742        return _rightManager.getUserRights(user, content);
743    }
744    
745    /**
746     * Get the tags of contents
747     * @param contentIds The content's ids
748     * @return the tags
749     */
750    @Callable
751    public Set<String> getTags (List<String> contentIds)
752    {
753        Set<String> tags = new HashSet<>();
754        
755        for (String contentId : contentIds)
756        {
757            Content content = _resolver.resolveById(contentId);
758            tags.addAll(content.getTags());
759        }
760        
761        return tags;
762    }
763    
764    /**
765     * Tag a list of contents with the given tags
766     * @param contentIds The ids of contents to tag
767     * @param tagNames The tags
768     * @param contextualParameters The contextual parameters
769     * @return the result
770     */
771    @Callable
772    public Map<String, Object> tag (List<String> contentIds, List<String> tagNames, Map<String, Object> contextualParameters)
773    {
774        return tag(contentIds, tagNames, TagMode.REPLACE.toString(), contextualParameters);
775    }
776    
777    /**
778     * Tag a list of contents
779     * @param contentIds The ids of contents to tag
780     * @param tagNames The tags
781     * @param mode The mode for updating tags: 'REPLACE' to replace tags, 'INSERT' to add tags or 'REMOVE' to remove tags.
782     * @param contextualParameters The contextual parameters
783     * @return the result
784     */
785    @Callable
786    public Map<String, Object> tag (List<String> contentIds, List<String> tagNames, String mode, Map<String, Object> contextualParameters)
787    {
788        Map<String, Object> result = new HashMap<>();
789        
790        result.put("notaggable-contents", new ArrayList<Map<String, Object>>());
791        result.put("invalid-tags", new ArrayList<String>());
792        result.put("allright-contents", new ArrayList<Map<String, Object>>());
793        result.put("locked-contents", new ArrayList<Map<String, Object>>());
794        
795        for (String contentId : contentIds)
796        {
797            Content content = _resolver.resolveById(contentId);
798            
799            Map<String, Object> content2json = new HashMap<>();
800            content2json.put("id", content.getId());
801            content2json.put("title", _contentHelper.getTitle(content));
802            
803            if (content instanceof TaggableAmetysObject)
804            {
805                TaggableAmetysObject mContent = (TaggableAmetysObject) content;
806                
807                boolean wasLocked = false;
808                
809                if (content instanceof LockableAmetysObject)
810                {
811                    LockableAmetysObject lockableContent = (LockableAmetysObject) content;
812                    UserIdentity user = _currentUserProvider.getUser();
813                    if (lockableContent.isLocked() && !LockHelper.isLockOwner(lockableContent, user))
814                    {
815                        @SuppressWarnings("unchecked")
816                        List<Map<String, Object>> lockedContents = (List<Map<String, Object>>) result.get("locked-contents");
817                        content2json.put("lockOwner", lockableContent.getLockOwner());
818                        lockedContents.add(content2json);
819                        
820                        // Stop process
821                        continue;
822                    }
823                    
824                    if (lockableContent.isLocked())
825                    {
826                        wasLocked = true;
827                        lockableContent.unlock();
828                    }
829                }
830                
831                TagMode tagMode = TagMode.valueOf(mode);
832                
833                Set<String> oldTags = mContent.getTags();
834                _removeAllTagsInReplaceMode(mContent, tagMode, oldTags);
835                
836                // Then set new tags
837                for (String tagName : tagNames)
838                {
839                    if (_isTagValid(tagName, contextualParameters))
840                    {
841                        if (TagMode.REMOVE.equals(tagMode))
842                        {
843                            mContent.untag(tagName);
844                        }
845                        else if (TagMode.REPLACE.equals(tagMode) || !oldTags.contains(tagName))
846                        {
847                            mContent.tag(tagName);
848                        }
849                        
850                    }
851                    else
852                    {
853                        @SuppressWarnings("unchecked")
854                        List<String> invalidTags = (List<String>) result.get("invalid-tags");
855                        invalidTags.add(tagName);
856                    }
857                }
858                
859                ((ModifiableAmetysObject) content).saveChanges();
860                
861                if (wasLocked)
862                {
863                    // Relock content if it was locked before tagging
864                    ((LockableAmetysObject) content).lock();
865                }
866                
867                content2json.put("tags", content.getTags());
868                @SuppressWarnings("unchecked")
869                List<Map<String, Object>> allRightPages = (List<Map<String, Object>>) result.get("allright-contents");
870                allRightPages.add(content2json);
871                
872                if (!oldTags.equals(content.getTags()))
873                {
874                    // Notify observers that the content has been tagged
875                    Map<String, Object> eventParams = new HashMap<>();
876                    eventParams.put(org.ametys.cms.ObservationConstants.ARGS_CONTENT, content);
877                    eventParams.put(org.ametys.cms.ObservationConstants.ARGS_CONTENT_ID, content.getId());
878                    eventParams.put("content.tags", content.getTags());
879                    eventParams.put("content.old.tags", oldTags);
880                    _observationManager.notify(new Event(org.ametys.cms.ObservationConstants.EVENT_CONTENT_TAGGED, _currentUserProvider.getUser(), eventParams));
881                }
882            }
883            else
884            {
885                @SuppressWarnings("unchecked")
886                List<Map<String, Object>> notaggableContents = (List<Map<String, Object>>) result.get("notaggable-contents");
887                notaggableContents.add(content2json);
888            }
889        }
890        
891        return result;
892    }
893
894    private void _removeAllTagsInReplaceMode(TaggableAmetysObject mContent, TagMode tagMode, Set<String> oldTags)
895    {
896        if (TagMode.REPLACE.equals(tagMode))
897        {
898            // First delete old tags
899            for (String tagName : oldTags)
900            {
901                mContent.untag(tagName);
902            }
903        }
904    }
905    
906    /**
907     * Is the tag a content tag
908     * @param tagName The tag name
909     * @param contextualParameters The contextual parameters
910     * @return true if the tag is a valid content tag
911     */
912    public boolean _isTagValid (String tagName, Map<String, Object> contextualParameters)
913    {
914        CMSTag tag = _tagProvider.getTag(tagName, contextualParameters);
915        return tag.getTarget().getName().equals("CONTENT");
916    }
917    
918    /**
919     * Copy a content.
920     * @param originalContent the original content.
921     * @param parent the object in which to create a content.
922     * @param name the content name.
923     * @param initWorkflowActionId The initial workflow action id
924     * @return the copied content.
925     * @throws AmetysRepositoryException If an error occured
926     */
927    public ModifiableContent copy(DefaultContent originalContent, ModifiableTraversableAmetysObject parent, String name, int initWorkflowActionId) throws AmetysRepositoryException
928    {
929        return copy(originalContent, parent, name, null, initWorkflowActionId);
930    }
931    
932    /**
933     * Copy a content.
934     * @param originalContent the original content.
935     * @param parent the object in which to create a content.
936     * @param name the content name.
937     * @param lang the content language. If null, the content language will be the same of the original content 
938     * @param initWorkflowActionId The initial workflow action id
939     * @return the copied content.
940     * @throws AmetysRepositoryException If an error occured
941     */
942    public ModifiableContent copy(DefaultContent originalContent, ModifiableTraversableAmetysObject parent, String name, String lang, int initWorkflowActionId) throws AmetysRepositoryException
943    {
944        return copy(originalContent, parent, name, lang, initWorkflowActionId, true, false, false);
945    }
946    
947    /**
948     * Copy a content.
949     * @param originalContent the original content.
950     * @param parent the object in which to create a content.
951     * @param name the content name.
952     * @param lang the content language. If null, the content language will be the same of the original content 
953     * @param initWorkflowActionId The initial workflow action id
954     * @param notifyObservers Set to false to do not fire observer events
955     * @param waitAsyncObservers true to wait for asynchronous observers to complete
956     * @param copyACL true to copy ACL of source content
957     * @return the copied content.
958     * @throws AmetysRepositoryException If an error occured
959     */
960    public ModifiableContent copy(DefaultContent originalContent, ModifiableTraversableAmetysObject parent, String name, String lang, int initWorkflowActionId, boolean notifyObservers, boolean waitAsyncObservers, boolean copyACL) throws AmetysRepositoryException
961    {
962        try
963        {
964            String originalName = name == null ? originalContent.getName() : name;
965            String contentName = originalName;
966            int index = 2;
967            while (parent.hasChild(contentName))
968            {
969                contentName = originalName + "-" + (index++);
970            }
971            
972            String originalContentType = originalContent.getNode().getPrimaryNodeType().getName();
973            
974            ModifiableContent content = parent.createChild(contentName, originalContentType);
975            
976            String targetLanguage = lang == null ? originalContent.getLanguage() : lang;
977            if (targetLanguage != null)
978            {
979                content.setLanguage(targetLanguage);
980            }
981            
982            content.setTypes(originalContent.getTypes());
983            
984            _modifiableContentHelper.copyTitle(originalContent, content);
985            
986            if (originalContent instanceof WorkflowAwareContent)
987            {
988                WorkflowAwareContent waOriginalContent = (WorkflowAwareContent) originalContent;
989                AmetysObjectWorkflow originalContentWorkflow = _workflowProvider.getAmetysObjectWorkflow(waOriginalContent);
990                String workflowName = originalContentWorkflow.getWorkflowName(waOriginalContent.getWorkflowId());
991                
992                // Initialize new content workflow
993                WorkflowAwareContent waContent = (WorkflowAwareContent) content;
994                AmetysObjectWorkflow workflow = _workflowProvider.getAmetysObjectWorkflow(waContent);
995                
996                HashMap<String, Object> inputs = new HashMap<>();
997                // Provide the content key
998                inputs.put(AbstractContentWorkflowComponent.CONTENT_KEY, waContent);
999                
1000                long workflowId = workflow.initialize(workflowName, initWorkflowActionId, inputs);
1001                waContent.setWorkflowId(workflowId);
1002                
1003                // Set the current step ID property
1004                Step currentStep = (Step) workflow.getCurrentSteps(workflowId).iterator().next();
1005                waContent.setCurrentStepId(currentStep.getStepId());
1006                
1007                Node workflowEntryNode = null;
1008                Node node = waContent.getNode();
1009                Session session = node.getSession();
1010                
1011                
1012                try
1013                {
1014                    AbstractJackrabbitWorkflowStore workflowStore = (AbstractJackrabbitWorkflowStore) workflow.getConfiguration().getWorkflowStore();
1015                    
1016                    if (workflowStore instanceof AmetysObjectWorkflowStore)
1017                    {
1018                        AmetysObjectWorkflowStore ametysObjectWorkflowStore = (AmetysObjectWorkflowStore) workflowStore;
1019                        ametysObjectWorkflowStore.bindAmetysObject(waContent);
1020                    }
1021                    
1022                    workflowEntryNode = workflowStore.getEntryNode(session, workflowId);
1023                    workflowEntryNode.setProperty("ametys-internal:initialActionId", initWorkflowActionId);
1024                }
1025                catch (RepositoryException e)
1026                {
1027                    throw new AmetysRepositoryException("Unable to link the workflow to the content", e);
1028                }
1029            }
1030            
1031            // Copy metadata
1032            originalContent.getMetadataHolder().copyTo(content.getMetadataHolder());
1033            
1034            // Copy attachments 
1035            _copyAttachments(originalContent, content);
1036            
1037            if (copyACL)
1038            {
1039                _copyACL(originalContent, content);
1040            }
1041            
1042            if (_currentUserProvider.getUser() != null)
1043            {
1044                content.setCreator(_currentUserProvider.getUser());
1045                content.setLastModified(new Date());
1046                content.setCreationDate(new Date());
1047            }
1048            
1049            parent.saveChanges();
1050            
1051            // Create a new version
1052            if (content instanceof VersionableAmetysObject)
1053            {
1054                ((VersionableAmetysObject) content).checkpoint();
1055            }
1056
1057            if (notifyObservers)
1058            {
1059                _notifyContentCopied(content, waitAsyncObservers);
1060            }
1061            
1062            return content;
1063        }
1064        catch (WorkflowException e)
1065        {
1066            throw new AmetysRepositoryException(e);
1067        }
1068        catch (RepositoryException e)
1069        {
1070            throw new AmetysRepositoryException(e);
1071        }
1072    }
1073    
1074    /**
1075     * Copy the attachments of a content
1076     * @param srcContent The source content
1077     * @param targetContent The target content
1078     */
1079    protected void _copyAttachments(Content srcContent, Content targetContent)
1080    {
1081        ResourceCollection srcRootAttachments = srcContent.getRootAttachments();
1082        ResourceCollection targetRootAttachments = targetContent.getRootAttachments();
1083        
1084        AmetysObjectIterable<AmetysObject> children = srcRootAttachments.getChildren();
1085        for (AmetysObject child : children)
1086        {
1087            if (child instanceof CopiableAmetysObject)
1088            {
1089                try
1090                {
1091                    ((CopiableAmetysObject) child).copyTo((ModifiableTraversableAmetysObject) targetRootAttachments, child.getName());
1092                }
1093                catch (AmetysRepositoryException e)
1094                {
1095                    getLogger().error("Failed to copy attachments at path " + child.getPath() + " from content " + srcContent + " to content " + targetContent, e);
1096                }
1097            }
1098        }
1099    }
1100        
1101    /**
1102     * Copy the ACL of a content
1103     * @param srcContent The source content
1104     * @param targetContent The target content
1105     */
1106    protected void _copyACL(Content srcContent, Content targetContent)
1107    {
1108        if (srcContent instanceof DefaultContent && targetContent instanceof DefaultContent)
1109        {
1110            Node srcNode = ((DefaultContent) srcContent).getNode();
1111            Node targetNode = ((DefaultContent) targetContent).getNode();
1112            
1113            try
1114            {
1115                String aclNodeName = "ametys-internal:acl";
1116                if (srcNode.hasNode(aclNodeName))
1117                {
1118                    Node aclNode = srcNode.getNode(aclNodeName);
1119                    aclNode.getSession().getWorkspace().copy(aclNode.getPath(), targetNode.getPath() + "/" + aclNodeName);
1120                }
1121            }
1122            catch (RepositoryException e)
1123            {
1124                getLogger().error("Failed to copy ACL from content " + srcContent + " to content " + targetContent, e);
1125            }
1126        }
1127    }
1128    
1129    /**
1130     * Notify observers that the content has been created
1131     * @param content The content added
1132     * @param waitAsyncObservers true to wait for asynchonous observers to finish
1133     * @throws WorkflowException If an error occurred
1134     */
1135    protected void _notifyContentCopied(Content content, boolean waitAsyncObservers) throws WorkflowException
1136    {
1137        Map<String, Object> eventParams = new HashMap<>();
1138        eventParams.put(ObservationConstants.ARGS_CONTENT, content);
1139        eventParams.put(ObservationConstants.ARGS_CONTENT_ID, content.getId());
1140
1141        List<Future> futures = _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_ADDED, _currentUserProvider.getUser(), eventParams));
1142        futures.addAll(_observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_WORKFLOW_CHANGED, _currentUserProvider.getUser(), eventParams)));
1143        
1144        if (waitAsyncObservers)
1145        {
1146            // Wait for asynchonous observers to finish
1147            for (Future future : futures)
1148            {
1149                try
1150                {
1151                    future.get();
1152                }
1153                catch (ExecutionException | InterruptedException e)
1154                {
1155                    getLogger().error(String.format("Error while waiting for async observer to complete"));
1156                }
1157            }
1158        }
1159    }
1160    
1161    /**
1162     * Returns the content's attachments root node
1163     * @param id the content's id
1164     * @return The attachments' root node informations
1165     */
1166    @Callable
1167    public Map<String, Object> getAttachmentsRootNode (String id)
1168    {
1169        Map<String, Object> result = new HashMap<>();
1170        
1171        Content content = _resolver.resolveById(id);
1172        
1173        result.put("title", _contentHelper.getTitle(content));
1174        result.put("contentId", content.getId());
1175        
1176        TraversableAmetysObject attachments = content.getRootAttachments();
1177        
1178        if (attachments != null)
1179        {
1180            result.put("id", attachments.getId());
1181            if (attachments instanceof ModifiableAmetysObject)
1182            {
1183                result.put("isModifiable", true);
1184            }
1185            if (attachments instanceof ModifiableResourceCollection)
1186            {
1187                result.put("canCreateChild", true);
1188            }
1189            
1190            boolean hasChildNodes = false;
1191            boolean hasResources = false;
1192
1193            for (AmetysObject child : attachments.getChildren())
1194            {
1195                if (child instanceof Resource)
1196                {
1197                    hasResources = true;
1198                }
1199                else if (child instanceof ExplorerNode)
1200                {
1201                    hasChildNodes = true;
1202                }
1203            }
1204
1205            if (hasChildNodes)
1206            {
1207                result.put("hasChildNodes", true);
1208            }
1209
1210            if (hasResources)
1211            {
1212                result.put("hasResources", true);
1213            }
1214            
1215            return result;
1216        }
1217        
1218        throw new IllegalArgumentException("Content with id '" + id + "' does not support attachments.");
1219    }
1220    
1221    /**
1222     * Determines if the current user has right to delete the content
1223     * @param content The content
1224     * @return true if current user is authorized to delete the content
1225     */
1226    public boolean canDelete(Content content)
1227    {
1228        return canDelete(content, "CMS_Rights_DeleteContent");
1229    }
1230    
1231    /**
1232     * Determines if the current user has right to delete the content
1233     * @param content The content
1234     * @param deleteRightId The right's id to check for deletion
1235     * @return true if current user is authorized to delete the content
1236     */
1237    public boolean canDelete(Content content, String deleteRightId)
1238    {
1239        UserIdentity user = _currentUserProvider.getUser();
1240        if (_rightManager.hasRight(user, deleteRightId, content) == RightResult.RIGHT_ALLOW)
1241        {
1242            return true;
1243        }
1244        
1245        return false;
1246    }
1247    
1248    /**
1249     * Add or remove a reaction on a content
1250     * @param contentId The content id
1251     * @param reactionName the reaction name (ex: LIKE)
1252     * @param remove true to remove the reaction, false to add reaction
1253     * @return the result with the current actors of this reaction
1254     */
1255    @Callable
1256    public Map<String, Object> react(String contentId, String reactionName, boolean remove)
1257    {
1258        Map<String, Object> result = new HashMap<>();
1259        
1260        Content content = _resolver.resolveById(contentId);
1261        
1262        if (_rightManager.currentUserHasReadAccess(content))
1263        {
1264            ReactionType reactionType = ReactionType.valueOf(reactionName);
1265            UserIdentity actor = _currentUserProvider.getUser();
1266            
1267            boolean updated = remove ? unreact(content, actor, reactionType) : react(content, actor, reactionType);
1268            result.put("updated", updated);
1269            result.put("contentId", contentId);
1270            result.put("actors", _userHelper.userIdentities2json(((ReactionableObject) content).getReactionUsers(reactionType)));
1271        }
1272        else
1273        {
1274            result.put("unauthorized", true);
1275            result.put("updated", false);
1276        }
1277
1278        return result;
1279    }
1280    
1281    /**
1282     * Add a reaction on a {@link Content}.
1283     * @param content the content
1284     * @param userIdentity the issuer of reaction
1285     * @param reactionType the reaction type
1286     * @return true if a change was made
1287     */
1288    public boolean react(Content content, UserIdentity userIdentity, ReactionType reactionType)
1289    {
1290        return _addOrRemoveReaction(content, userIdentity, reactionType, false);
1291    }
1292    
1293    /**
1294     * Remove reaction if exists on a {@link Content}.
1295     * @param content the content
1296     * @param userIdentity the issuer of reaction
1297     * @param reactionType the reaction type
1298     * @return true if a change was made
1299     */
1300    public boolean unreact(Content content, UserIdentity userIdentity, ReactionType reactionType)
1301    {
1302        return _addOrRemoveReaction(content, userIdentity, reactionType, true);
1303    }
1304    
1305    private boolean _addOrRemoveReaction(Content content, UserIdentity userIdentity, ReactionType reactionType, boolean remove)
1306    {
1307        if (content instanceof ReactionableObject)
1308        {
1309            boolean hasChanges = false;
1310            
1311            List<UserIdentity> reactionIssuers = ((ReactionableObject) content).getReactionUsers(reactionType);
1312            if (!remove && !reactionIssuers.contains(userIdentity))
1313            {
1314                ((ReactionableObject) content).addReaction(userIdentity, reactionType);
1315                hasChanges = true;
1316            }
1317            else if (remove && reactionIssuers.contains(userIdentity))
1318            {
1319                ((ReactionableObject) content).removeReaction(userIdentity, reactionType);
1320                hasChanges = true;
1321            }
1322            
1323            if (hasChanges)
1324            {
1325                ((DefaultContent) content).saveChanges();
1326                
1327                Map<String, Object> eventParams = new HashMap<>();
1328                eventParams.put(ObservationConstants.ARGS_CONTENT, content);
1329                eventParams.put(ObservationConstants.ARGS_REACTION_TYPE, reactionType);
1330                eventParams.put(ObservationConstants.ARGS_REACTION_ISSUER, userIdentity);
1331                _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_REACTION_CHANGED, userIdentity, eventParams));
1332                
1333                return true;
1334            }
1335        }
1336        
1337        return false;
1338    }
1339    
1340    /**
1341     * Add a report on a content
1342     * @param content The content
1343     * @throws IllegalArgumentException if the content is not a {@link ReportableObject}
1344     * @throws AccessDeniedException if the current user has not read access on the given content
1345     */
1346    public void report(Content content) throws IllegalArgumentException, AccessDeniedException
1347    {
1348        if (_rightManager.currentUserHasReadAccess(content))
1349        {
1350            if (content instanceof ReportableObject)
1351            {
1352                ((ReportableObject) content).addReport();
1353                ((DefaultContent) content).saveChanges();
1354            }
1355            else
1356            {
1357                throw new IllegalArgumentException("Unable to report the content '" + content.getId() + "'. Current user is not authorized to see this content.");
1358            }
1359        }
1360        else
1361        {
1362            throw new AccessDeniedException("Unable to report the content '" + content.getId() + "'. Current user is not authorized to see this content.");
1363        }
1364    }
1365}