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}