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