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