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