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