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