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