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