001/* 002 * Copyright 2014 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.workflow; 017 018import java.lang.reflect.Array; 019import java.util.ArrayList; 020import java.util.Arrays; 021import java.util.Collection; 022import java.util.Collections; 023import java.util.Date; 024import java.util.HashMap; 025import java.util.HashSet; 026import java.util.List; 027import java.util.Map; 028import java.util.Map.Entry; 029import java.util.Optional; 030import java.util.Set; 031 032import org.apache.avalon.framework.activity.Initializable; 033import org.apache.commons.collections4.CollectionUtils; 034import org.apache.commons.lang3.ArrayUtils; 035import org.apache.commons.lang3.StringUtils; 036import org.apache.commons.lang3.tuple.Pair; 037 038import org.ametys.cms.ObservationConstants; 039import org.ametys.cms.content.references.OutgoingReferences; 040import org.ametys.cms.content.references.OutgoingReferencesExtractor; 041import org.ametys.cms.contenttype.AttributeDefinition; 042import org.ametys.cms.contenttype.ContentAttributeDefinition; 043import org.ametys.cms.contenttype.ContentType; 044import org.ametys.cms.contenttype.ContentTypeExtensionPoint; 045import org.ametys.cms.contenttype.ContentTypesHelper; 046import org.ametys.cms.contenttype.ContentValidator; 047import org.ametys.cms.data.ContentDataHelper; 048import org.ametys.cms.data.ContentSynchronizationContext; 049import org.ametys.cms.data.ContentSynchronizationResult; 050import org.ametys.cms.data.ContentValue; 051import org.ametys.cms.data.ReferencedContents; 052import org.ametys.cms.model.restrictions.RestrictedModelItem; 053import org.ametys.cms.repository.Content; 054import org.ametys.cms.repository.ModifiableContent; 055import org.ametys.cms.repository.WorkflowAwareContent; 056import org.ametys.core.observation.Event; 057import org.ametys.core.observation.ObservationManager; 058import org.ametys.core.user.User; 059import org.ametys.core.user.UserIdentity; 060import org.ametys.core.user.UserManager; 061import org.ametys.core.util.DateUtils; 062import org.ametys.plugins.repository.AmetysRepositoryException; 063import org.ametys.plugins.repository.data.DataComment; 064import org.ametys.plugins.repository.data.external.ExternalizableDataProvider.ExternalizableDataStatus; 065import org.ametys.plugins.repository.data.external.ExternalizableDataProviderExtensionPoint; 066import org.ametys.plugins.repository.data.holder.group.Repeater; 067import org.ametys.plugins.repository.data.holder.impl.DataHolderHelper; 068import org.ametys.plugins.repository.data.holder.values.SynchronizableRepeater; 069import org.ametys.plugins.repository.data.holder.values.SynchronizableValue; 070import org.ametys.plugins.repository.data.holder.values.SynchronizableValue.Mode; 071import org.ametys.plugins.repository.data.holder.values.SynchronizationResult; 072import org.ametys.plugins.repository.data.holder.values.UntouchedValue; 073import org.ametys.plugins.repository.lock.LockHelper; 074import org.ametys.plugins.repository.lock.LockableAmetysObject; 075import org.ametys.plugins.repository.model.CompositeDefinition; 076import org.ametys.plugins.repository.model.RepeaterDefinition; 077import org.ametys.plugins.workflow.component.CheckRightsCondition; 078import org.ametys.runtime.authentication.AccessDeniedException; 079import org.ametys.runtime.config.Config; 080import org.ametys.runtime.i18n.I18nizableText; 081import org.ametys.runtime.i18n.I18nizableTextParameter; 082import org.ametys.runtime.model.ElementDefinition; 083import org.ametys.runtime.model.ModelHelper; 084import org.ametys.runtime.model.ModelItem; 085import org.ametys.runtime.model.ModelItemContainer; 086import org.ametys.runtime.model.ModelViewItemGroup; 087import org.ametys.runtime.model.View; 088import org.ametys.runtime.model.ViewHelper; 089import org.ametys.runtime.model.ViewItemContainer; 090import org.ametys.runtime.model.type.DataContext; 091import org.ametys.runtime.model.type.ElementType; 092import org.ametys.runtime.parameter.Errors; 093import org.ametys.runtime.parameter.Validator; 094 095import com.opensymphony.module.propertyset.PropertySet; 096import com.opensymphony.workflow.FunctionProvider; 097import com.opensymphony.workflow.WorkflowException; 098 099/** 100 * OSWorkflow function to edit a content.<br> 101 * <br> 102 * Values are set either programmatically, or parsed from form submission by their {@link ElementType}s according to the {@link Content} model.<br> 103 * <br> 104 * The required transient variables:<br> 105 * - AbstractContentWorkflowComponent.RESULT_MAP_KEY - Map<String, Object> The map containing the results of the function.<br> 106 * - AbstractContentWorkflowComponent.RESULT_MAP_KEY.result - String "true" when everything goes fine. Missing in other case.<br> 107 * - AbstractContentWorkflowComponent.RESULT_MAP_KEY.<MetadataPath> - Errors Each error during edition will be set here. Key will be the metadata path (with '.' separator). Value will be the error message.<br> 108 * - AbstractContentWorkflowComponent.CONTENT_KEY - WorkflowAwareContent The content that will be edited. Should have the lock token.<br> 109 * - AbstractWorkflowComponent.CONTEXT_PARAMETERS_KEY - Map<String, Object> Contains the following parameters:<br> 110 * - AbstractWorkflowComponent.CONTEXT_PARAMETERS_KEY.QUIT - boolean True to specify edition mode will be quit, this imply to unlock the content.<br> 111 * - AbstractWorkflowComponent.CONTEXT_PARAMETERS_KEY.VIEW_PARAM The name of the view to use and to check attributes. If missing a view will be created from values. <br> 112 * - AbstractWorkflowComponent.CONTEXT_PARAMETERS_KEY.FALLBACK_VIEW_PARAM The name of the view to use if the initial view does not exist on the Content's model. <br> 113 * - AbstractWorkflowComponent.CONTEXT_PARAMETERS_KEY.VALUES_KEY - Map<String, Object> The typed values. If present, raw values must not be present.<br> 114 * - AbstractWorkflowComponent.CONTEXT_PARAMETERS_KEY.FORM_RAW_VALUES - Map<String, Object> The values of the submitted form. If present, types values must not be present.<br> 115 * - AbstractWorkflowComponent.CONTEXT_PARAMETERS_KEY.FORM_RAW_VALUES.<MetadataPath> Object Key is the path of the metadata ('.' separated) prefixed by FORM_ELEMENTS_PREFIX. Value is a depending on the type of metadata. 116 * Sometimes types require additional information. In that case : Key is a metadata path ('.' separated) prefixed by INTERNAL_FORM_ELEMENTS_PREFIX and suffixed by '.' + an additional information name.<br> 117 * - AbstractWorkflowComponent.CONTEXT_PARAMETERS_KEY.FORM_RAW_COMMENTS - Map<String, List<Map<String, String>>> The comments of the metadata of the submitted form :<br> 118 * - AbstractContentWorkflowComponent.RESULT_MAP_KEY.<MetadataPath> - List<Map<String, String>> Key is the path of the metadata ('.' separated) prefixed by FORM_ELEMENTS_PREFIX. Value is the list of comments.<br> 119 * - AbstractContentWorkflowComponent.RESULT_MAP_KEY.<MetadataPath>.<X> - <Map<String, String> A comment with the following parameters<br> 120 * - AbstractContentWorkflowComponent.RESULT_MAP_KEY.<MetadataPath>.<X>.author String The login of the author of the comment<br> 121 * - AbstractContentWorkflowComponent.RESULT_MAP_KEY.<MetadataPath>.<X>.text String The text of the comment<br> 122 * - AbstractContentWorkflowComponent.RESULT_MAP_KEY.<MetadataPath>.<X>.date String The date of the comment using the ISODateTimeFormat (See DateUtils.parse)<br> 123 * 124 * Where <MetadataPath> is the path of a metadata (using a '.' separator). In some cases it is prefixed by FORM_ELEMENTS_PREFIX. A metadata path with in a repeater include the number of the repeated instance (1 based).<br> 125 * Where <X> Is an element of the parent list.<br> 126 */ 127public class EditContentFunction extends AbstractContentWorkflowComponent implements FunctionProvider, Initializable 128{ 129 /** Constant for storing the action id for editing revert relations. */ 130 public static final String INVERT_RELATION_EDIT_WORKFLOW_ACTION_ID = EditContentFunction.class.getName() + "$invertEditActionId"; 131 132 /** Prefix for HTML form elements. */ 133 public static final String FORM_ELEMENTS_PREFIX = "content.input."; 134 /** The key for global errors */ 135 public static final String GLOBAL_ERROR_KEY = "_global"; 136 /** Prefix for internal HTML form elements. */ 137 public static final String INTERNAL_FORM_ELEMENTS_PREFIX = "_" + FORM_ELEMENTS_PREFIX; 138 /** Inputs key for typed values. */ 139 public static final String VALUES_KEY = "typedValues"; 140 /** Request parameter key for the field values. */ 141 public static final String FORM_RAW_VALUES = "values"; 142 /** Request parameter key for the field comments. */ 143 public static final String FORM_RAW_COMMENTS = "comments"; 144 /** View parameter. */ 145 public static final String VIEW = "view"; 146 /** View items parameter. */ 147 public static final String VIEW_ITEMS = "view.items"; 148 /** View name parameter. */ 149 public static final String VIEW_NAME = "content.view"; 150 /** Fallback view name parameter. */ 151 public static final String FALLBACK_VIEW_NAME = "content.fallback.view"; 152 /** Quit edition mode parameter. */ 153 public static final String QUIT = "quit"; 154 /** Local only parameter. */ 155 public static final String LOCAL_ONLY = "local.only"; 156 /** Optional previous synchronization result */ 157 public static final String SYNCHRONIZATION_RESULT = "synchronization.result"; 158 /** Default action id of editing revert relations. */ 159 public static final int INVERT_EDIT_ACTION_ID = 2; 160 161 /** Content type extension point. */ 162 protected ContentTypeExtensionPoint _contentTypeExtensionPoint; 163 /** Helper for content types */ 164 protected ContentTypesHelper _contentTypesHelper; 165 /** Observation manager available to subclasses. */ 166 protected ObservationManager _observationManager; 167 /** The content workflow helper. */ 168 protected ContentWorkflowHelper _workflowHelper; 169 /** The outgoing references extractor */ 170 protected OutgoingReferencesExtractor _outgoingReferencesExtractor; 171 /** The user manager */ 172 protected UserManager _userManager; 173 /** Provider for externalizable data */ 174 protected ExternalizableDataProviderExtensionPoint _externalizableDataProviderEP; 175 /** Helper for collecting content references */ 176 protected ContentDataHelper _contentDataHelper; 177 178 @Override 179 public void initialize() throws Exception 180 { 181 _contentTypeExtensionPoint = (ContentTypeExtensionPoint) _manager.lookup(ContentTypeExtensionPoint.ROLE); 182 _observationManager = (ObservationManager) _manager.lookup(ObservationManager.ROLE); 183 _workflowHelper = (ContentWorkflowHelper) _manager.lookup(ContentWorkflowHelper.ROLE); 184 _contentTypesHelper = (ContentTypesHelper) _manager.lookup(ContentTypesHelper.ROLE); 185 _outgoingReferencesExtractor = (OutgoingReferencesExtractor) _manager.lookup(OutgoingReferencesExtractor.ROLE); 186 _userManager = (UserManager) _manager.lookup(UserManager.ROLE); 187 _externalizableDataProviderEP = (ExternalizableDataProviderExtensionPoint) _manager.lookup(ExternalizableDataProviderExtensionPoint.ROLE); 188 _contentDataHelper = (ContentDataHelper) _manager.lookup(ContentDataHelper.ROLE); 189 } 190 191 @SuppressWarnings("unchecked") 192 @Override 193 public void execute(Map transientVars, Map args, PropertySet ps) throws WorkflowException 194 { 195 _logger.info("Performing edit workflow function"); 196 197 // Retrieve current content 198 WorkflowAwareContent content = getContent(transientVars); 199 UserIdentity user = getUser(transientVars); 200 201 if (!(content instanceof ModifiableContent)) 202 { 203 throw new IllegalArgumentException("The provided content " + content.getId() + " is not a ModifiableContent."); 204 } 205 206 ModifiableContent modifiableContent = (ModifiableContent) content; 207 208 try 209 { 210 LockableAmetysObject lockableContent = _checkLock(content, user); 211 212 AllErrors errors = new AllErrors(); 213 214 Map<String, Object> parameters = getContextParameters(transientVars); 215 216 long time_0 = System.currentTimeMillis(); 217 218 // get inputs, either typed values (eg. set programmatically) 219 // or raw values (eg. from request parameters) 220 221 Map<String, Object> typedValues = (Map<String, Object>) parameters.get(VALUES_KEY); 222 Map<String, Object> rawValues = (Map<String, Object>) parameters.get(FORM_RAW_VALUES); 223 224 if (typedValues != null && rawValues != null) 225 { 226 throw new WorkflowException("Cannot have both typed values and raw values for EditContentFunction"); 227 } 228 229 if (typedValues == null && rawValues == null) 230 { 231 typedValues = Collections.EMPTY_MAP; 232 } 233 234 // get the view, either set from inputs or computed from values 235 View view = getView(parameters, typedValues, rawValues, modifiableContent); 236 237 long time_1 = System.currentTimeMillis(); 238 239 // get the attributes comments 240 Map<String, List<Map<String, String>>> rawComments = (Map<String, List<Map<String, String>>>) parameters.get(FORM_RAW_COMMENTS); 241 242 boolean localOnly = (boolean) parameters.getOrDefault(LOCAL_ONLY, false); 243 244 // get values 245 Map<String, Object> values = getValues(view, modifiableContent, typedValues, rawValues, rawComments, localOnly, transientVars); 246 247 // validate values 248 validateValues(view, modifiableContent, values, errors, transientVars); 249 globalValidate(view, modifiableContent, values, errors); 250 251 // prepare synchronize 252 // FIXME CMS-10952: find external invert relations 253 Collection<ReferencedContents> referencedContents = prepareSynchronize(modifiableContent, view, values, user, errors, transientVars); 254 255 _handleErrors(transientVars, modifiableContent, errors); 256 257 long time_2 = System.currentTimeMillis(); 258 259 // Notify the observers of the upcoming modification. 260 notifyContentModifying(content, values, transientVars); 261 262 // actually write changes 263 SynchronizationResult synchronizationResult = synchronize(modifiableContent, view, values, referencedContents, transientVars); 264 265 // Aggregate the synchronization result with the one in transient var if any 266 synchronizationResult.aggregateResult((SynchronizationResult) parameters.getOrDefault(SYNCHRONIZATION_RESULT, new SynchronizationResult())); 267 268 updateCommonMetadata(modifiableContent, user, synchronizationResult); 269 270 extractOutgoingReferences(modifiableContent, synchronizationResult); 271 272 long time_3 = System.currentTimeMillis(); 273 274 // Commit changes 275 modifiableContent.saveChanges(); 276 277 long time_4 = System.currentTimeMillis(); 278 279 // Notify the observers of the modification. 280 notifyContentModified(content, transientVars, synchronizationResult); 281 282 long time_5 = System.currentTimeMillis(); 283 284 // Unlock content if we are not in save & quit mode 285 Boolean quit = (Boolean) parameters.get(QUIT); 286 if (Boolean.TRUE.equals(quit) && lockableContent != null && lockableContent.isLocked()) 287 { 288 lockableContent.unlock(); 289 } 290 291 long time_6 = System.currentTimeMillis(); 292 293 boolean logAbnormalTime = Config.getInstance().getValue("runtime.log.abnormal.time"); 294 if (time_6 - time_0 > 5000 && logAbnormalTime) 295 { 296 _logger.warn("Edit content action has taken an abnormally long time : get view in " + (time_1 - time_0) + " ms / bind attributes in " + (time_2 - time_1) + " ms / build consistencies in " + (time_3 - time_2) + " ms / save in " + (time_4 - time_3) + " / notify listeners in " + (time_5 - time_4) + " / total in " + (time_6 - time_0) + " ms"); 297 } 298 else if (_logger.isDebugEnabled()) 299 { 300 _logger.debug("Edit timers : get view in " + (time_1 - time_0) + " ms / bind attributes in " + (time_2 - time_1) + " ms / build consistencies in " + (time_3 - time_2) + " ms / save in " + (time_4 - time_3) + " / notify listeners in " + (time_5 - time_4) + " / total in " + (time_6 - time_0) + " ms"); 301 } 302 303 Map<String, Object> resultsMap = getResultsMap(transientVars); 304 resultsMap.put("result", "ok"); 305 resultsMap.put(HAS_CHANGED_KEY, synchronizationResult.hasChanged()); 306 } 307 catch (AmetysRepositoryException | AccessDeniedException e) 308 { 309 throw new WorkflowException("Unable to edit content " + modifiableContent + " from the repository", e); 310 } 311 } 312 313 private LockableAmetysObject _checkLock(Content content, UserIdentity user) throws WorkflowException 314 { 315 LockableAmetysObject lockableContent = null; 316 317 if (content instanceof LockableAmetysObject) 318 { 319 lockableContent = (LockableAmetysObject) content; 320 if (lockableContent.isLocked() && !LockHelper.isLockOwner(lockableContent, user)) 321 { 322 throw new WorkflowException("User '" + user + "' try to save content '" + content.getName() + "' but it is locked by another user"); 323 } 324 } 325 326 return lockableContent; 327 } 328 329 private void _handleErrors(Map transientVars, ModifiableContent modifiableContent, AllErrors errors) throws WorkflowException, InvalidInputWorkflowException 330 { 331 if (errors.hasErrors()) 332 { 333 // Populate the map to render 334 Map<String, Object> result = getResultsMap(transientVars); 335 336 Map<String, I18nizableText> errorFieldLabels = new HashMap<>(); 337 338 for (Map.Entry<String, Errors> entry : errors.getAllErrors().entrySet()) 339 { 340 String dataPath = entry.getKey(); 341 String canonicalMetadataPath = entry.getKey().replace('/', '.'); 342 343 result.put(canonicalMetadataPath, entry.getValue()); 344 345 if (modifiableContent.hasDefinition(dataPath)) 346 { 347 ModelItem definition = modifiableContent.getDefinition(dataPath); 348 errorFieldLabels.put(canonicalMetadataPath, definition.getLabel()); 349 } 350 } 351 352 result.put("errorFieldLabels", errorFieldLabels); 353 354 throw new InvalidInputWorkflowException("At least one validation error is preventing from saving the modifications", errors); 355 } 356 } 357 358 /** 359 * Get the identifier of the invert edit action 360 * @param transientVars The workflow vars 361 * @param referencedContent the content concerned by the invert relation 362 * @return the identifier of the invert edit action 363 */ 364 protected int getInvertEditActionId(Map transientVars, Content referencedContent) 365 { 366 return transientVars.containsKey(INVERT_RELATION_EDIT_WORKFLOW_ACTION_ID) ? (Integer) transientVars.get(INVERT_RELATION_EDIT_WORKFLOW_ACTION_ID) : INVERT_EDIT_ACTION_ID; 367 } 368 369 /** 370 * Notify observers that the content is being modified 371 * @param content The content being modified 372 * @param values the new values being set to the content 373 * @param transientVars The workflow vars 374 * @throws WorkflowException If an error occurred 375 */ 376 protected void notifyContentModifying(Content content, Map<String, Object> values, Map transientVars) throws WorkflowException 377 { 378 Map<String, Object> eventParams = new HashMap<>(); 379 eventParams.put(ObservationConstants.ARGS_CONTENT, content); 380 eventParams.put(ObservationConstants.ARGS_CONTENT_ID, content.getId()); 381 eventParams.put(ObservationConstants.ARGS_CONTENT_VALUES, values); 382 383 _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_MODIFYING, getUser(transientVars), eventParams)); 384 } 385 386 /** 387 * Notify observers that the content has been modified 388 * @param content The content modified 389 * @param transientVars The workflow vars 390 * @param synchronizationResult The result of the content values synchronization 391 * @throws WorkflowException If an error occurred 392 */ 393 protected void notifyContentModified(Content content, Map transientVars, SynchronizationResult synchronizationResult) throws WorkflowException 394 { 395 Map<String, Object> eventParams = new HashMap<>(); 396 eventParams.put(ObservationConstants.ARGS_CONTENT, content); 397 eventParams.put(ObservationConstants.ARGS_CONTENT_ID, content.getId()); 398 399 _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_MODIFIED, getUser(transientVars), eventParams)); 400 } 401 402 /** 403 * Get the view for the content 404 * @param parameters The parameters 405 * @param values Typed values from inputs 406 * @param rawValues The raw values of the form 407 * @param content The content 408 * @return The view asked in the request or a built-in view 409 * @throws WorkflowException If an error occurred while getting the view 410 */ 411 @SuppressWarnings("unchecked") 412 protected View getView(Map<String, Object> parameters, Map<String, Object> values, Map<String, Object> rawValues, Content content) throws WorkflowException 413 { 414 View view = (View) parameters.get(VIEW); 415 if (view == null) 416 { 417 List<String> viewItems = (List<String>) parameters.get(VIEW_ITEMS); 418 if (viewItems != null) 419 { 420 view = ViewHelper.createViewItemAccessor(content.getModel(), viewItems.toArray(String[]::new)); 421 } 422 else 423 { 424 String viewName = (String) parameters.get(VIEW_NAME); 425 String fallbackViewName = (String) parameters.get(FALLBACK_VIEW_NAME); 426 if (viewName != null) 427 { 428 view = _contentTypesHelper.getViewWithFallback(viewName, fallbackViewName, content.getTypes(), content.getMixinTypes()); 429 } 430 } 431 } 432 433 if (view != null) 434 { 435 if (ViewHelper.areItemsPresentsOnlyOnce(view)) 436 { 437 return ViewHelper.getTruncatedView(view); 438 } 439 else 440 { 441 throw new WorkflowException("The view '" + view.getName() + "' of content '" + content + "'cannot be used to edit the content because one or more items do not appear only once."); 442 } 443 } 444 445 // Compute a view from input values 446 String[] computedViewItems; 447 Collection<? extends ModelItemContainer> model = content.getModel(); 448 if (values != null) 449 { 450 Set<String> items; 451 items = new HashSet<>(); 452 _getViewItems(values, model, content, items); 453 computedViewItems = items.toArray(String[]::new); 454 } 455 else 456 { 457 computedViewItems = rawValues.keySet().stream() 458 .filter(path -> path.startsWith(FORM_ELEMENTS_PREFIX)) 459 .map(path -> path.substring(FORM_ELEMENTS_PREFIX.length())) 460 .map(ModelHelper::getDefinitionPathFromDataPath) 461 .toArray(String[]::new); 462 } 463 464 return ViewHelper.createViewItemAccessor(model, computedViewItems); 465 } 466 467 @SuppressWarnings("unchecked") 468 private void _getViewItems(Map<String, Object> values, Collection<? extends ModelItemContainer> parent, Content content, Set<String> viewItems) 469 { 470 for (String name : values.keySet()) 471 { 472 ModelItem modelItem = ModelHelper.getModelItem(name, parent); 473 String path = modelItem.getPath(); 474 475 if (!(modelItem instanceof RestrictedModelItem) || ((RestrictedModelItem) modelItem).canWrite(content)) 476 { 477 if (modelItem instanceof AttributeDefinition) 478 { 479 viewItems.add(path); 480 } 481 else if (modelItem instanceof CompositeDefinition) 482 { 483 Object value = values.get(name); 484 485 if (!(value instanceof Map)) 486 { 487 throw new IllegalArgumentException("CompositeDefinition should correspond to a Map<String, Object> value."); 488 } 489 490 Map<String, Object> composite = (Map<String, Object>) value; 491 492 if (composite.isEmpty()) 493 { 494 // composite is empty, we should add it anyway so that the corresponding storage could also be emptied 495 viewItems.add(path); 496 } 497 else 498 { 499 _getViewItems(composite, Collections.singleton((CompositeDefinition) modelItem), content, viewItems); 500 } 501 } 502 else if (modelItem instanceof RepeaterDefinition) 503 { 504 Object value = values.get(name); 505 506 if (!(value instanceof List) && !(value instanceof SynchronizableRepeater)) 507 { 508 throw new IllegalArgumentException("RepeaterDefinition should correspond to a SynchronizableRepeater or List<Map<String, Object>> value."); 509 } 510 511 List<Map<String, Object>> entries = value instanceof SynchronizableRepeater ? ((SynchronizableRepeater) value).getEntries() : (List<Map<String, Object>>) value; 512 513 if (entries.isEmpty()) 514 { 515 // repeater is empty, we should add it anyway so that the corresponding storage could also be emptied 516 viewItems.add(path); 517 } 518 else 519 { 520 for (int i = 0; i < entries.size(); i++) 521 { 522 Map<String, Object> entry = entries.get(i); 523 _getViewItems(entry, Collections.singleton((RepeaterDefinition) modelItem), content, viewItems); 524 } 525 } 526 } 527 } 528 } 529 } 530 531 /** 532 * Computes the actual typed values from the input. 533 * @param view the current {@link View} 534 * @param content the current Content 535 * @param typedValues typed values, if any 536 * @param rawValues raw values from form, if any 537 * @param rawComments the form comments, if any 538 * @param localOnly if the form values are local only or may include external values 539 * @param transientVars the parameters from the call. 540 * @return the actual values to be set 541 * @throws WorkflowException If an error occurred 542 */ 543 protected Map<String, Object> getValues(View view, ModifiableContent content, Map<String, Object> typedValues, Map<String, Object> rawValues, Map<String, List<Map<String, String>>> rawComments, boolean localOnly, Map transientVars) throws WorkflowException 544 { 545 Map<String, Object> values = typedValues; 546 if (values == null) 547 { 548 values = _parseValues(view, StringUtils.EMPTY, Optional.of(StringUtils.EMPTY), content, rawValues, rawComments, localOnly); 549 } 550 else 551 { 552 values = _convertValues(content, view, values, transientVars); 553 } 554 555 // TODO CMS-10082: Evaluate disable conditions 556 // values = _processDisableConditions(view, values, values, ""); 557 558 return values; 559 } 560 561 @SuppressWarnings("unchecked") 562 private Map<String, Object> _parseValues(ViewItemContainer viewItemContainer, String prefix, Optional<String> oldPrefix, ModifiableContent content, Map<String, Object> rawValues, Map<String, List<Map<String, String>>> rawComments, boolean localOnly) 563 { 564 Map<String, Object> values = new HashMap<>(); 565 566 org.ametys.plugins.repository.model.ViewHelper.visitView(viewItemContainer, 567 (element, definition) -> { 568 // simple element 569 String name = definition.getName(); 570 ElementType type = definition.getType(); 571 572 Object value; 573 if (!((AttributeDefinition) definition).canWrite(content)) 574 { 575 value = new UntouchedValue(); 576 } 577 else 578 { 579 String dataPath = prefix + name; 580 581 // For the fromJSONForClient method, the context needs the path of the data as it is in the repository 582 // So we compute the old data path, i.e. with the repeater entries previous positions 583 Optional<String> oldDataPath = oldPrefix.map(p -> p + name); 584 DataContext dataContext = DataContext.newInstance() 585 .withObjectId(content.getId()); 586 587 // If the entry did not exist, the optional prefix is empty 588 if (oldDataPath.isPresent()) 589 { 590 dataContext.withDataPath(oldDataPath.get()); 591 } 592 593 Object initialValue = rawValues.get(FORM_ELEMENTS_PREFIX + dataPath); 594 Object rawValue = initialValue; 595 ExternalizableDataStatus status = null; 596 Object externalValue = null; 597 598 // if the value is externalizable, rawValue is actually a Map {local:<value>, external:<value>, status:<local or external>} 599 if (!localOnly && _externalizableDataProviderEP.isDataExternalizable(content, definition)) 600 { 601 Map<String, Object> externalizableValue = (Map<String, Object>) initialValue; 602 603 status = ExternalizableDataStatus.valueOf(((String) externalizableValue.get("status")).toUpperCase()); 604 rawValue = externalizableValue.get("local"); 605 606 Object rawExternalValue = externalizableValue.get("external"); 607 externalValue = type.fromJSONForClient(rawExternalValue, dataContext); 608 } 609 610 // get the typed value 611 Object typedValue = type.fromJSONForClient(rawValue, dataContext); 612 613 // retrieve the associated comments 614 List<Map<String, String>> comments = rawComments == null ? null : rawComments.get(FORM_ELEMENTS_PREFIX + prefix + name); 615 616 value = _getSynchronizableValue(typedValue, status, externalValue, comments); 617 } 618 619 values.put(name, value); 620 }, 621 (group, definition) -> { 622 // composite 623 String name = definition.getName(); 624 String updatedPrefix = prefix + name + ModelItem.ITEM_PATH_SEPARATOR; 625 Optional<String> updatedOldPrefix = oldPrefix.map(p -> p + name + ModelItem.ITEM_PATH_SEPARATOR); 626 values.put(name, _parseValues(group, updatedPrefix, updatedOldPrefix, content, rawValues, rawComments, localOnly)); 627 }, 628 (group, definition) -> { 629 // repeater 630 String name = definition.getName(); 631 632 if (definition instanceof RestrictedModelItem && !((RestrictedModelItem) definition).canWrite(content)) 633 { 634 values.put(name, new UntouchedValue()); 635 } 636 else 637 { 638 int size = (int) rawValues.get(INTERNAL_FORM_ELEMENTS_PREFIX + prefix + name + "/size"); 639 640 List<Map<String, Object>> entries = new ArrayList<>(); 641 Map<Integer, Integer> mapping = new HashMap<>(); 642 for (int i = 1; i <= size; i++) 643 { 644 String updatedPrefix = prefix + name + "[" + i + "]" + ModelItem.ITEM_PATH_SEPARATOR; 645 Optional<String> updatedOldPrefix = Optional.empty(); 646 int previousPosition = (int) rawValues.get(INTERNAL_FORM_ELEMENTS_PREFIX + prefix + name + "[" + i + "]/previous-position"); 647 if (previousPosition > 0) 648 { 649 updatedOldPrefix = oldPrefix.map(p -> p + name + "[" + previousPosition + "]" + ModelItem.ITEM_PATH_SEPARATOR); 650 mapping.put(previousPosition, i); 651 } 652 653 entries.add(_parseValues(group, updatedPrefix, updatedOldPrefix, content, rawValues, rawComments, localOnly)); 654 } 655 656 values.put(name, SynchronizableRepeater.replaceAll(entries, mapping)); 657 } 658 659 }, 660 group -> values.putAll(_parseValues(group, prefix, oldPrefix, content, rawValues, rawComments, localOnly))); 661 662 return values; 663 } 664 665 private Object _getSynchronizableValue(Object localValue, ExternalizableDataStatus status, Object externalValue, List<Map<String, String>> rawComments) 666 { 667 SynchronizableValue result = new SynchronizableValue(localValue); 668 result.setExternalizableStatus(status != null ? status : ExternalizableDataStatus.LOCAL); 669 result.setExternalValue(externalValue); 670 671 if (rawComments != null) 672 { 673 List<DataComment> comments = new ArrayList<>(); 674 675 for (Map<String, String> rawComment : rawComments) 676 { 677 String author = rawComment.get("author"); 678 String text = rawComment.get("text"); 679 String rawDate = rawComment.get("date"); 680 681 DataComment comment = new DataComment(text, DateUtils.parseZonedDateTime(rawDate), author); 682 683 comments.add(comment); 684 } 685 686 result.setComments(comments); 687 } 688 689 return result; 690 } 691 692 @SuppressWarnings("unchecked") 693 private Map<String, Object> _convertValues(ModifiableContent content, ViewItemContainer viewItemContainer, Map<String, Object> values, Map transientVars) 694 { 695 if (values == null) 696 { 697 return null; 698 } 699 700 Map<String, Object> result = new HashMap<>(); 701 702 org.ametys.plugins.repository.model.ViewHelper.visitView(viewItemContainer, 703 (element, definition) -> { 704 // simple element 705 String name = definition.getName(); 706 707 if (values.containsKey(name)) 708 { 709 Object value = values.get(name); 710 if (value instanceof SynchronizableValue) 711 { 712 SynchronizableValue syncValue = (SynchronizableValue) value; 713 syncValue.setLocalValue(_convertValue(definition, syncValue.getLocalValue(), transientVars)); 714 syncValue.setExternalValue(_convertValue(definition, syncValue.getExternalValue(), transientVars)); 715 result.put(name, syncValue); 716 } 717 else 718 { 719 value = _convertValue(definition, value, transientVars); 720 SynchronizableValue syncValue = new SynchronizableValue(value, getValueExternalizableDataStatus(content, definition, transientVars)); 721 result.put(name, syncValue); 722 } 723 } 724 }, 725 (group, definition) -> { 726 // composite 727 String name = definition.getName(); 728 if (values.containsKey(name)) 729 { 730 result.put(name, _convertValues(content, group, (Map<String, Object>) values.get(name), transientVars)); 731 } 732 }, 733 (group, definition) -> { 734 // repeater 735 String name = definition.getName(); 736 if (values.containsKey(name)) 737 { 738 Object value = values.get(name); 739 SynchronizableRepeater syncRepeater = value instanceof SynchronizableRepeater ? (SynchronizableRepeater) value : null; 740 List<Map<String, Object>> entries = value == null ? null : value instanceof List ? (List<Map<String, Object>>) value : ((SynchronizableRepeater) value).getEntries(); 741 742 Object newValue = null; 743 if (entries != null) 744 { 745 List<Map<String, Object>> newEntries = new ArrayList<>(); 746 747 for (int i = 0; i < entries.size(); i++) 748 { 749 newEntries.add(_convertValues(content, group, entries.get(i), transientVars)); 750 } 751 752 newValue = newEntries; 753 754 if (syncRepeater != null) 755 { 756 newValue = SynchronizableRepeater.copy(syncRepeater, newEntries); 757 } 758 } 759 760 result.put(name, newValue); 761 } 762 }, 763 group -> result.putAll(_convertValues(content, group, values, transientVars))); 764 765 return result; 766 } 767 768 /** 769 * Converts the given value according to the given definition 770 * If the value is multiple, an array is retrieved with each value converted to the right type 771 * @param definition the definition 772 * @param value the value to convert 773 * @param transientVars the parameters from the call. 774 * @return the converted value 775 */ 776 protected Object _convertValue(ElementDefinition definition, Object value, Map transientVars) 777 { 778 return DataHolderHelper.convertValue(definition, value); 779 } 780 781 /** 782 * Get the status of the value to modify 783 * @param content the content 784 * @param definition the definition of the value 785 * @param transientVars the parameters from the call 786 * @return the status of the value 787 */ 788 protected ExternalizableDataStatus getValueExternalizableDataStatus(Content content, ModelItem definition, Map transientVars) 789 { 790 return ExternalizableDataStatus.LOCAL; 791 } 792 793 /*private Map<String, Object> _processDisableConditions(ViewItemContainer viewItemContainer, Map<String, Object> values, Map<String, Object> rootValues, String prefix) 794 { 795 org.ametys.plugins.repository.model.ViewHelper.visitView(viewItemContainer, 796 (element, definition) -> { 797 // attribute 798 if (ModelHelper.evaluateDisableConditions(definition.getDisableConditions(), null, _logger)) 799 { 800 values.put(definition.getName(), new UntouchedValue()); 801 } 802 }, 803 (group, definition) -> { 804 // composite 805 }, 806 (group, definition) -> { 807 // repeater 808 }, 809 group -> { 810 // group 811 } 812 ); 813 814 return values; 815 }*/ 816 817 /** 818 * Validates all input values. 819 * @param view the model's view corresponding to the values 820 * @param content the current content 821 * @param values the actual input values 822 * @param allErrors object to be populated with validation errors 823 * @param transientVars the parameters from the call 824 * @throws WorkflowException If an error occurred 825 */ 826 protected void validateValues(View view, ModifiableContent content, Map<String, Object> values, AllErrors allErrors, Map transientVars) throws WorkflowException 827 { 828 _validateValues(view, StringUtils.EMPTY, Optional.of(StringUtils.EMPTY), content, Optional.of(values), allErrors, transientVars); 829 } 830 831 private void _validateValues(ViewItemContainer viewItemContainer, String prefix, Optional<String> oldPrefix, ModifiableContent content, Optional<Map<String, Object>> values, AllErrors allErrors, Map transientVars) 832 { 833 org.ametys.plugins.repository.model.ViewHelper.visitView(viewItemContainer, 834 (element, definition) -> { 835 // simple element 836 String name = definition.getName(); 837 838 String dataPath = prefix + name; 839 Optional<String> oldDataPath = oldPrefix.map(p -> p + name); 840 841 Object value = values.map(v -> v.get(name)).orElse(null); 842 validateValue(definition, dataPath, oldDataPath, content, value, allErrors, transientVars); 843 }, 844 (group, definition) -> { 845 // composite 846 String name = definition.getName(); 847 848 String updatedPrefix = prefix + name + ModelItem.ITEM_PATH_SEPARATOR; 849 Optional<String> updatedOldPrefix = oldPrefix.map(p -> p + name + ModelItem.ITEM_PATH_SEPARATOR); 850 851 Optional<Map<String, Object>> value = values.map(v -> v.get(name)).filter(Map.class::isInstance).map(Map.class::cast); 852 _validateValues(group, updatedPrefix, updatedOldPrefix, content, value, allErrors, transientVars); 853 }, 854 (group, definition) -> { 855 // repeater 856 String name = definition.getName(); 857 858 String dataPath = prefix + name; 859 Optional<String> oldDataPath = oldPrefix.map(p -> p + name); 860 861 Object value = values.map(v -> v.get(name)).orElse(null); 862 validateRepeaterValue(group, definition, dataPath, oldDataPath, content, value, allErrors, transientVars); 863 }, 864 group -> _validateValues(group, prefix, oldPrefix, content, values, allErrors, transientVars)); 865 } 866 867 /** 868 * Validate an attribute value. 869 * @param definition the attribute definition. 870 * @param dataPath the attribute path. 871 * @param oldDataPath the old data path, i.e. with the repeater entries previous positions. Used to know the current status for externalizable data 872 * @param content the Content being edited. 873 * @param value the value. 874 * @param allErrors the errors. 875 * @param transientVars the parameters from the call. 876 */ 877 protected void validateValue(ElementDefinition definition, String dataPath, Optional<String> oldDataPath, ModifiableContent content, Object value, AllErrors allErrors, Map transientVars) 878 { 879 Object actualValue = DataHolderHelper.getValueFromSynchronizableValue(value, content, definition, oldDataPath, getSynchronizationContext(transientVars)); 880 Mode mode = value instanceof SynchronizableValue ? ((SynchronizableValue) value).getMode() : null; 881 882 if (mode == null) 883 { 884 mode = Mode.REPLACE; 885 } 886 887 if (actualValue instanceof UntouchedValue) 888 { 889 // don't validate UntouchedValue, either they correspond to non-writable or previously stored data 890 return; 891 } 892 893 if (!((AttributeDefinition) definition).canWrite(content)) 894 { 895 throw new EditContentAccessDeniedException(content, definition); 896 } 897 898 Validator validator = definition.getValidator(); 899 Object valueToValidate = actualValue; 900 if (validator != null && validator.getClass().isAnnotationPresent(NeedAllValues.class)) 901 { 902 // the validator need all attribute values 903 Object oldValue = content.getValue(dataPath); 904 if (definition.isMultiple()) 905 { 906 Object[] oldValuesArray = (Object[]) oldValue; 907 Object[] newValuesArray = (Object[]) actualValue; 908 valueToValidate = mode == Mode.REPLACE ? actualValue : mode == Mode.APPEND ? ArrayUtils.addAll(oldValuesArray, newValuesArray) : CollectionUtils.disjunction(Arrays.asList(oldValuesArray), Arrays.asList(newValuesArray)).toArray(i -> (Object[]) Array.newInstance(definition.getType().getManagedClass(), i)); 909 } 910 else 911 { 912 valueToValidate = mode != Mode.REMOVE ? actualValue : null; 913 } 914 } 915 916 List<I18nizableText> errors = ModelHelper.validateValue(definition, valueToValidate); 917 918 if (errors != null && !errors.isEmpty()) 919 { 920 Errors e = new Errors(); 921 e.addErrors(errors); 922 allErrors.addError(dataPath, e); 923 } 924 } 925 926 /** 927 * Validate repeater values. 928 * @param viewItem the view item referencing the repeater 929 * @param definition the repeater definition. 930 * @param dataPath the repeater path. 931 * @param oldDataPath the old repeater data path, i.e. with the repeater entries previous positions. Used to know the current status for externalizable data 932 * @param content the Content being edited. 933 * @param value the value. 934 * @param allErrors the errors. 935 * @param transientVars the parameters from the call. 936 */ 937 @SuppressWarnings("unchecked") 938 protected void validateRepeaterValue(ModelViewItemGroup viewItem, RepeaterDefinition definition, String dataPath, Optional<String> oldDataPath, ModifiableContent content, Object value, AllErrors allErrors, Map transientVars) 939 { 940 if (value instanceof UntouchedValue) 941 { 942 // don't validate UntouchedValue, either they correspond to non-writable or previously stored data 943 return; 944 } 945 946 if (definition instanceof RestrictedModelItem && !((RestrictedModelItem<Content>) definition).canWrite(content)) 947 { 948 throw new EditContentAccessDeniedException(content, definition); 949 } 950 951 List<Map<String, Object>> entries = value == null ? null : value instanceof List ? (List<Map<String, Object>>) value : ((SynchronizableRepeater) value).getEntries(); 952 SynchronizableRepeater.Mode mode = value instanceof SynchronizableRepeater ? ((SynchronizableRepeater) value).getMode() : SynchronizableRepeater.Mode.REPLACE_ALL; 953 954 int oldRepeaterSize = 0; 955 if (mode != SynchronizableRepeater.Mode.REPLACE_ALL) 956 { 957 958 Repeater repeater = oldDataPath.map(path -> content.getRepeater(path)).orElse(null); 959 if (repeater != null) 960 { 961 oldRepeaterSize = repeater.getSize(); 962 } 963 } 964 965 int repeaterSize = entries != null ? entries.size() : 0; 966 967 if (mode == SynchronizableRepeater.Mode.APPEND) 968 { 969 SynchronizableRepeater repeater = (SynchronizableRepeater) value; 970 assert repeater != null; 971 repeaterSize = oldRepeaterSize + repeaterSize - repeater.getRemovedEntries().size(); 972 } 973 else if (mode == SynchronizableRepeater.Mode.REPLACE) 974 { 975 repeaterSize = oldRepeaterSize; 976 } 977 978 _validateRepeaterSize(definition, dataPath, repeaterSize, allErrors); 979 980 if (entries != null) 981 { 982 for (int i = 0; i < entries.size(); i++) 983 { 984 Map<String, Object> entry = entries.get(i); 985 String prefix = dataPath + "[" + (i + 1) + "]" + ModelItem.ITEM_PATH_SEPARATOR; 986 Optional<String> oldPrefix = _getRepeaterEntryOldPrefix(oldDataPath, value, i + 1); 987 _validateValues(viewItem, prefix, oldPrefix, content, Optional.of(entry), allErrors, transientVars); 988 } 989 } 990 } 991 992 private void _validateRepeaterSize(RepeaterDefinition definition, String dataPath, int repeaterSize, AllErrors allErrors) 993 { 994 int minSize = definition.getMinSize(); 995 int maxSize = definition.getMaxSize(); 996 997 if (repeaterSize < minSize) 998 { 999 Errors errors = new Errors(); 1000 1001 List<String> parameters = new ArrayList<>(); 1002 parameters.add(definition.getName()); 1003 parameters.add(Integer.toString(minSize)); 1004 errors.addError(new I18nizableText("plugin.cms", "CONTENT_EDITION_VALIDATION_ERRORS_REPEATER_MINSIZE", parameters)); 1005 allErrors.addError(dataPath, errors); 1006 } 1007 1008 if (maxSize > 0 && repeaterSize > maxSize) 1009 { 1010 Errors errors = new Errors(); 1011 1012 List<String> parameters = new ArrayList<>(); 1013 parameters.add(definition.getName()); 1014 parameters.add(Integer.toString(maxSize)); 1015 errors.addError(new I18nizableText("plugin.cms", "CONTENT_EDITION_VALIDATION_ERRORS_REPEATER_MAXSIZE", parameters)); 1016 allErrors.addError(dataPath, errors); 1017 } 1018 } 1019 1020 private Optional<String> _getRepeaterEntryOldPrefix(Optional<String> oldDataPath, Object value, int currentPosition) 1021 { 1022 Optional<String> oldPrefix = Optional.empty(); 1023 if (value instanceof SynchronizableRepeater) 1024 { 1025 Optional<Integer> previousPosition = ((SynchronizableRepeater) value).getPreviousPosition(currentPosition); 1026 if (previousPosition.isPresent()) 1027 { 1028 oldPrefix = oldDataPath.map(path -> path + "[" + previousPosition.get() + "]" + ModelItem.ITEM_PATH_SEPARATOR); 1029 } 1030 } 1031 return oldPrefix; 1032 } 1033 1034 /** 1035 * Performs a global validation of the Content, based on declared {@link ContentValidator}s. 1036 * @param view the current {@link View} 1037 * @param content the current {@link Content}. 1038 * @param values the values being set 1039 * @param allErrors object to be populated with validation errors 1040 */ 1041 protected void globalValidate(View view, Content content, Map<String, Object> values, AllErrors allErrors) 1042 { 1043 Errors errors = new Errors(); 1044 1045 String[] allContentTypes = ArrayUtils.addAll(content.getTypes(), content.getMixinTypes()); 1046 1047 for (String cTypeId : allContentTypes) 1048 { 1049 ContentType contentType = _contentTypeExtensionPoint.getExtension(cTypeId); 1050 1051 for (ContentValidator validator : contentType.getGlobalValidators()) 1052 { 1053 validator.validate(content, values, view, errors); 1054 } 1055 } 1056 1057 if (errors.hasErrors()) 1058 { 1059 // Global error 1060 allErrors.addError(GLOBAL_ERROR_KEY, errors); 1061 } 1062 } 1063 1064 /** 1065 * Prepares the write process by checking remote contents concerned by invert relations. 1066 * @param content the current content. 1067 * @param view the current View. 1068 * @param values the new values. 1069 * @param user the current user 1070 * @param allErrors the collected errors 1071 * @param transientVars the parameters from the call. 1072 * @return the {@link ReferencedContents} 1073 */ 1074 protected Collection<ReferencedContents> prepareSynchronize(ModifiableContent content, View view, Map<String, Object> values, UserIdentity user, AllErrors allErrors, Map transientVars) 1075 { 1076 if (!invertRelationEnabled()) 1077 { 1078 return null; 1079 } 1080 1081 Collection<ReferencedContents> referencedContents = _contentDataHelper.collectReferencedContents(view, content, values, getSynchronizationContext(transientVars)); 1082 1083 Map<ContentValue, Pair<Boolean, String>> refContents = new HashMap<>(); 1084 1085 // "flatten" the data, so that we only lock each content once 1086 // for each ref content, we only keep the first dataPath (for error reporting) and the weakest value for forceInvert (ie. false if any) 1087 for (ReferencedContents referencedContent : referencedContents) 1088 { 1089 ContentAttributeDefinition definition = referencedContent.getDefinition(); 1090 boolean forceInvert = definition.getForceInvert(); 1091 1092 _flattenCollectedReferencedContents(referencedContent.getAddedContentsWithPaths(), refContents, forceInvert); 1093 _flattenCollectedReferencedContents(referencedContent.getRemovedContentsWithPaths(), refContents, forceInvert); 1094 } 1095 1096 for (Entry<ContentValue, Pair<Boolean, String>> value : refContents.entrySet()) 1097 { 1098 ContentValue refContentValue = value.getKey(); 1099 ModifiableContent refContent = refContentValue.getContentIfExists().orElse(null); 1100 1101 if (refContent != null) 1102 { 1103 // Check if edit action is available on referenced contents 1104 int invertEditActionId = getInvertEditActionId(transientVars, refContent); 1105 if (_isEditRefContentAvailable(invertEditActionId, refContent, value.getValue().getLeft(), value.getValue().getRight(), user, allErrors)) 1106 { 1107 if (refContent instanceof LockableAmetysObject && !((LockableAmetysObject) refContent).isLocked()) 1108 { 1109 // Get lock on referenced content 1110 ((LockableAmetysObject) refContent).lock(); 1111 } 1112 } 1113 } 1114 } 1115 1116 return referencedContents; 1117 } 1118 1119 private void _flattenCollectedReferencedContents(Map<ContentValue, List<String>> references, Map<ContentValue, Pair<Boolean, String>> refContents, boolean forceInvert) 1120 { 1121 for (Entry<ContentValue, List<String>> value : references.entrySet()) 1122 { 1123 ContentValue refContentValue = value.getKey(); 1124 List<String> dataPaths = value.getValue(); 1125 1126 Pair<Boolean, String> invertData = refContents.get(refContentValue); 1127 if (invertData == null) 1128 { 1129 String firstData = dataPaths.isEmpty() ? "" : dataPaths.get(0); 1130 refContents.put(refContentValue, Pair.of(forceInvert, firstData)); 1131 } 1132 else if (!forceInvert && invertData.getLeft()) 1133 { 1134 String firstData = dataPaths.isEmpty() ? "" : dataPaths.get(0); 1135 refContents.put(refContentValue, Pair.of(forceInvert, firstData)); 1136 } 1137 } 1138 } 1139 1140 private boolean _isEditRefContentAvailable(int editActionId, Content refContent, boolean forceInvert, String currentMetadataPath, UserIdentity user, AllErrors allErrors) 1141 { 1142 if (refContent instanceof WorkflowAwareContent) 1143 { 1144 Map<String, Object> inputs = new HashMap<>(); 1145 if (forceInvert) 1146 { 1147 // do not check user's right 1148 inputs.put(CheckRightsCondition.FORCE, true); 1149 } 1150 1151 int[] availableActions = _workflowHelper.getAvailableActions((WorkflowAwareContent) refContent, inputs); 1152 if (!ArrayUtils.contains(availableActions, editActionId)) 1153 { 1154 Errors errors = new Errors(); 1155 Map<String, I18nizableTextParameter> params = new HashMap<>(); 1156 1157 // Check lock 1158 if (refContent instanceof LockableAmetysObject) 1159 { 1160 LockableAmetysObject lockableContent = (LockableAmetysObject) refContent; 1161 if (lockableContent.isLocked() && !LockHelper.isLockOwner(lockableContent, user)) 1162 { 1163 User lockOwner = _userManager.getUser(lockableContent.getLockOwner().getPopulationId(), lockableContent.getLockOwner().getLogin()); 1164 1165 params.put("content", new I18nizableText(_contentHelper.getTitle(refContent))); 1166 params.put("lockOwner", new I18nizableText(lockOwner != null ? lockOwner.getFullName() + " (" + lockOwner.getIdentity().getLogin() + ")" : lockableContent.getLockOwner().getLogin())); 1167 errors.addError(new I18nizableText("plugin.cms", "CONTENT_EDITION_VALIDATION_ERRORS_MUTUALRELATION_REFERENCED_CONTENT_LOCKED", params)); 1168 allErrors.addError(currentMetadataPath, errors); 1169 1170 return false; 1171 } 1172 } 1173 1174 // Action in unavailable 1175 params.put("content", new I18nizableText(_contentHelper.getTitle(refContent))); 1176 errors.addError(new I18nizableText("plugin.cms", "CONTENT_EDITION_VALIDATION_ERRORS_MUTUALRELATION_UNAVAILABLE_ACTION", params)); 1177 allErrors.addError(currentMetadataPath, errors); 1178 1179 return false; 1180 } 1181 else 1182 { 1183 return true; 1184 } 1185 } 1186 else 1187 { 1188 Errors errors = new Errors(); 1189 Map<String, I18nizableTextParameter> params = new HashMap<>(); 1190 params.put("content", new I18nizableText(_contentHelper.getTitle(refContent))); 1191 errors.addError(new I18nizableText("plugin.cms", "CONTENT_EDITION_VALIDATION_ERRORS_MUTUALRELATION_NOWORKFLOWAWARE_CONTENT", params)); 1192 allErrors.addError(currentMetadataPath, errors); 1193 return false; 1194 } 1195 } 1196 1197 /** 1198 * Synchronize the values of the given content 1199 * @param content the content to synchronize 1200 * @param view the content's view to use for synchronization 1201 * @param values the values to synchronize 1202 * @param referencedContents the contents referenced by invert relations 1203 * @param transientVars the parameters from the call. 1204 * @return The result of the synchronization 1205 * @throws WorkflowException if an error occurs while triggering the edition workflow action for related contents 1206 */ 1207 protected SynchronizationResult synchronize(ModifiableContent content, View view, Map<String, Object> values, Collection<ReferencedContents> referencedContents, Map transientVars) throws WorkflowException 1208 { 1209 ContentSynchronizationContext context = getSynchronizationContext(transientVars) 1210 .withInvertRelations(invertRelationEnabled()) 1211 .withReferencedContents(referencedContents); 1212 1213 ContentSynchronizationResult synchronizationResult = content.synchronizeValues(view, values, context); 1214 1215 ContentSynchronizationResult additionalOperationsResult = additionalOperations(content, transientVars); 1216 synchronizationResult.aggregateResult(additionalOperationsResult); 1217 1218 // trigger edit workflow action on contents modified due to invert relations 1219 for (ModifiableContent refContent : synchronizationResult.getModifiedContents()) 1220 { 1221 refContent.saveChanges(); 1222 int invertEditActionId = getInvertEditActionId(transientVars, refContent); 1223 triggerInvertWorkflowAction(refContent, invertEditActionId); 1224 } 1225 1226 1227 return synchronizationResult; 1228 } 1229 1230 /** 1231 * Retrieves the synchronization context 1232 * @param transientVars the parameters from the call 1233 * @return the synchronization context 1234 */ 1235 protected ContentSynchronizationContext getSynchronizationContext(Map transientVars) 1236 { 1237 return ContentSynchronizationContext.newInstance(); 1238 } 1239 1240 /** 1241 * Allow to do some other modifications on the given content before saving changes 1242 * @param content the content 1243 * @param transientVars the parameters from the call 1244 * @return The synchronization result od additional operations 1245 * @throws WorkflowException If an error occurred 1246 */ 1247 protected ContentSynchronizationResult additionalOperations(ModifiableContent content, Map transientVars) throws WorkflowException 1248 { 1249 // do nothing by default 1250 return new ContentSynchronizationResult(); 1251 } 1252 1253 /** 1254 * Updates common metadata (last contributor, last modification date, ...). 1255 * @param content the content. 1256 * @param user the user. 1257 * @param synchronizationResult The result of the content values synchronization 1258 * @throws WorkflowException if an error occurs. 1259 */ 1260 protected void updateCommonMetadata(ModifiableContent content, UserIdentity user, SynchronizationResult synchronizationResult) throws WorkflowException 1261 { 1262 if (user != null) 1263 { 1264 content.setLastContributor(user); 1265 } 1266 1267 content.setLastModified(new Date()); 1268 1269 if (content instanceof WorkflowAwareContent) 1270 { 1271 // Remove the proposal date. 1272 ((WorkflowAwareContent) content).setProposalDate(null); 1273 } 1274 } 1275 1276 /** 1277 * Analyze the content to extract outgoing references and store them 1278 * @param content The content to analyze 1279 * @param synchronizationResult The result of the content values synchronization 1280 */ 1281 protected void extractOutgoingReferences(ModifiableContent content, SynchronizationResult synchronizationResult) 1282 { 1283 Map<String, OutgoingReferences> outgoingReferencesByPath = _outgoingReferencesExtractor.getOutgoingReferences(content); 1284 content.setOutgoingReferences(outgoingReferencesByPath); 1285 } 1286 1287 /** 1288 * Template method to indicates if invert relation should be taken into account during the whole edition. 1289 * Override and return false to disabled invert relation management. 1290 * @return true if invert relation are enabled 1291 */ 1292 protected boolean invertRelationEnabled() 1293 { 1294 return true; 1295 } 1296 1297 /** 1298 * Trigger a 'edit content' workflow action (if the content is workflow-aware). 1299 * @param content The content. 1300 * @param actionId The current 'edit content' action ID. 1301 * @throws WorkflowException if an error occurs. 1302 */ 1303 protected void triggerInvertWorkflowAction(Content content, int actionId) throws WorkflowException 1304 { 1305 if (content instanceof WorkflowAwareContent) 1306 { 1307 // The content has already been modified by this function 1308 SynchronizationResult synchronizationResult = new SynchronizationResult(); 1309 synchronizationResult.setHasChanged(true); 1310 1311 Map<String, Object> parameters = new HashMap<>(); 1312 parameters.put(SYNCHRONIZATION_RESULT, synchronizationResult); 1313 parameters.put(QUIT, true); 1314 1315 Map<String, Object> inputs = new HashMap<>(); 1316 inputs.put(CONTEXT_PARAMETERS_KEY, parameters); 1317 1318 _workflowHelper.doAction((WorkflowAwareContent) content, actionId, inputs); 1319 } 1320 } 1321}