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