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