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