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