001/*
002 *  Copyright 2025 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.odf.workflow;
017
018import java.util.ArrayList;
019import java.util.List;
020import java.util.Map;
021import java.util.Set;
022import java.util.stream.Collectors;
023
024import org.apache.avalon.framework.service.ServiceException;
025import org.apache.avalon.framework.service.ServiceManager;
026import org.apache.commons.lang3.StringUtils;
027
028import org.ametys.cms.data.type.impl.BooleanRepositoryElementType;
029import org.ametys.cms.repository.Content;
030import org.ametys.cms.repository.ModifiableContent;
031import org.ametys.cms.workflow.EditContentFunction;
032import org.ametys.core.right.RightManager;
033import org.ametys.core.right.RightManager.RightResult;
034import org.ametys.odf.EducationalPathHelper;
035import org.ametys.odf.data.EducationalPath;
036import org.ametys.odf.rights.ODFRightHelper.ContextualizedContent;
037import org.ametys.plugins.repository.data.holder.values.SynchronizableRepeater;
038import org.ametys.plugins.repository.data.holder.values.SynchronizableValue;
039import org.ametys.plugins.repository.model.RepeaterDefinition;
040import org.ametys.plugins.repository.model.ViewHelper;
041import org.ametys.runtime.model.ModelHelper;
042import org.ametys.runtime.model.ModelItem;
043import org.ametys.runtime.model.ModelItemGroup;
044import org.ametys.runtime.model.View;
045import org.ametys.runtime.model.ViewItemContainer;
046import org.ametys.runtime.model.disableconditions.DisableCondition;
047import org.ametys.runtime.model.disableconditions.DisableConditions;
048import org.ametys.runtime.model.type.DataContext;
049
050import com.opensymphony.module.propertyset.PropertySet;
051import com.opensymphony.workflow.WorkflowException;
052
053/**
054 * Edit content function restricted to contextualized data, ie. data into repeater with educational path
055 * This function allows to edit data by educational path for which the user is responsible.
056 */
057public class EditContextualizedDataFunction extends EditContentFunction
058{
059    /** The workflow action id to edit repeater with educational path only */
060    public static final int EDIT_WORKFLOW_ACTION_ID = 20;
061    
062    /** Name of educational path attribute in a repeater with education path */
063    public static final String REPEATER_EDUCATIONAL_PATH_ATTRIBUTE_NAME = "path";
064    /** Name of common attribute in a repeater with education path (means that entry is common to all educational paths)*/
065    public static final String REPEATER_COMMON_ATTRIBUTE_NAME = "common";
066    
067    private EducationalPathHelper _educationalPathHelper;
068    private RightManager _rightManager;
069    private String _rightId;
070    
071    @Override
072    public void service(ServiceManager smanager) throws ServiceException
073    {
074        super.service(smanager);
075        _educationalPathHelper = (EducationalPathHelper) smanager.lookup(EducationalPathHelper.ROLE);
076        _rightManager = (RightManager) smanager.lookup(RightManager.ROLE);
077    }
078    
079    @Override
080    public void execute(Map transientVars, Map args, PropertySet ps) throws WorkflowException
081    {
082        _rightId = (String) args.get("right");
083        super.execute(transientVars, args, ps);
084    }
085    
086    @Override
087    protected boolean canWriteModelItem(ModelItem modelItem, Content content, Map transientVars)
088    {
089        // Only repeater with educational path and its child model items are writable
090        // Always enable boolean "common" item into a repeater with educational path (whatever model item's restrictions)
091        return _inRepeaterWithEducationalPath(modelItem) && (_isCommonBooleanItem(modelItem) || super.canWriteModelItem(modelItem, content, transientVars));
092    }
093    
094    private boolean _isCommonBooleanItem(ModelItem modelItem)
095    {
096        return modelItem.getType() instanceof BooleanRepositoryElementType
097                && modelItem.getName().equals(REPEATER_COMMON_ATTRIBUTE_NAME)
098                && modelItem.getParent() != null
099                && modelItem.getParent() instanceof RepeaterDefinition;
100    }
101    
102    private boolean _inRepeaterWithEducationalPath(ModelItem modelItem)
103    {
104        if (modelItem instanceof RepeaterDefinition repeaterDefinition && _educationalPathHelper.isRepeaterWithEducationalPath(repeaterDefinition))
105        {
106            return true;
107        }
108        
109        ModelItemGroup parentModelItem = modelItem.getParent();
110        return parentModelItem != null ? _inRepeaterWithEducationalPath(parentModelItem) : false;
111    }
112    
113    @Override
114    protected Map<String, Object> parseValues(View view, ModifiableContent content, Map<String, Object> rawValues, boolean localOnly, Map transientVars)
115    {
116        Map<String, Object> values = super.parseValues(view, content, rawValues, localOnly, transientVars);
117        
118        _parseRepeaterWithPathValues(content, view, values);
119        
120        return values;
121    }
122
123    @SuppressWarnings("unchecked")
124    private void _parseRepeaterWithPathValues(Content content, ViewItemContainer viewItemContainer, Map<String, Object> values)
125    {
126        ViewHelper.visitView(viewItemContainer,
127            (element, definition) -> {
128                // nothing
129            },
130            (group, definition) -> {
131                // composite
132                String name = definition.getName();
133                Map<String, Object> composite = (Map<String, Object>) values.get(name);
134                if (composite != null)
135                {
136                    _parseRepeaterWithPathValues(content, group, composite);
137                }
138            },
139            (group, definition) -> {
140                // repeater
141                String name = definition.getName();
142                List<Map<String, Object>> entries = ((SynchronizableRepeater) values.get(name)).getEntries();
143                if (entries != null)
144                {
145                    if (_educationalPathHelper.isRepeaterWithEducationalPath(definition))
146                    {
147                        values.put(name, _updateRepeaterWithPathValues(content, entries, definition));
148                    }
149                    else
150                    {
151                        for (Map<String, Object> entry : entries)
152                        {
153                            _parseRepeaterWithPathValues(content, group, entry);
154                        }
155                    }
156                }
157            },
158            group -> _parseRepeaterWithPathValues(content, group, values));
159    }
160    
161    
162    // Update repeater values. Only entries with allowed path will be really updated
163    private List<Map<String, Object>> _updateRepeaterWithPathValues(Content content, List<Map<String, Object>> repeaterValues, RepeaterDefinition repeaterDefinition)
164    {
165        List<Map<String, Object>> updatedValues = new ArrayList<>();
166        
167        String repeaterPath = repeaterDefinition.getPath();
168        
169        if (content.hasValue(repeaterPath))
170        {
171            // Get common entries and entries with unauthorized path from content
172            List<Map<String, Object>> entries = _getRepeaterEntries(content, repeaterDefinition);
173            for (Map<String, Object> entry : entries)
174            {
175                boolean commonEntry = _isCommonEntry(repeaterDefinition, entry);
176                EducationalPath educationalPath = (EducationalPath) entry.get(REPEATER_EDUCATIONAL_PATH_ATTRIBUTE_NAME);
177                if (commonEntry || educationalPath != null && _rightManager.currentUserHasRight(_rightId, new ContextualizedContent(content, educationalPath)) != RightResult.RIGHT_ALLOW)
178                {
179                    // user is not allowed to update its entries
180                    updatedValues.add(entry);
181                }
182            }
183        }
184        
185        // Get from client only entries with authorized path and force common attribute to false
186        for (Map<String, Object> entryValues : repeaterValues)
187        {
188            if (entryValues.containsKey(REPEATER_EDUCATIONAL_PATH_ATTRIBUTE_NAME))
189            {
190                boolean commonEntry = _isCommonEntry(repeaterDefinition, entryValues);
191                Object value = entryValues.get(REPEATER_EDUCATIONAL_PATH_ATTRIBUTE_NAME);
192                value = value instanceof SynchronizableValue synchronizableValue ? synchronizableValue.getLocalValue() : value;
193                
194                if (!commonEntry && value instanceof EducationalPath educationalPath)
195                {
196                    if (_rightManager.currentUserHasRight(_rightId, new ContextualizedContent(content, educationalPath)) == RightResult.RIGHT_ALLOW)
197                    {
198                        if (repeaterDefinition.hasModelItem(REPEATER_COMMON_ATTRIBUTE_NAME))
199                        {
200                            entryValues.put(REPEATER_COMMON_ATTRIBUTE_NAME, false); // Force common to false (required to pass disable condition)
201                        }
202                        updatedValues.add(entryValues);
203                    }
204                }
205            }
206        }
207        
208        return updatedValues;
209    }
210    
211    private boolean _isCommonEntry(RepeaterDefinition repeaterDefinition, Map<String, Object> entryValues)
212    {
213        boolean commonEntry = repeaterDefinition.hasModelItem(REPEATER_COMMON_ATTRIBUTE_NAME); // default true if repeater supports common entries
214        if (entryValues.containsKey(REPEATER_COMMON_ATTRIBUTE_NAME))
215        {
216            Object commonValue = entryValues.get(REPEATER_COMMON_ATTRIBUTE_NAME);
217            commonValue = commonValue instanceof SynchronizableValue synchronizableValue ? synchronizableValue.getLocalValue() : commonValue;
218            if (commonValue != null)
219            {
220                commonEntry = (boolean) commonValue;
221            }
222        }
223        return commonEntry;
224    }
225    
226    private List<Map<String, Object>> _getRepeaterEntries(Content content, RepeaterDefinition repeaterDefinition)
227    {
228        List<String> itemPaths = new ArrayList<>();
229        itemPaths.add(repeaterDefinition.getPath());
230        
231        // Add items needed to evaluate disabled conditions
232        itemPaths.addAll(_getDisabledConditionPaths(repeaterDefinition));
233        
234        View view = View.of(content.getModel(), itemPaths.toArray(String[]::new));
235        
236        Map<String, Object> dataToMap = content.dataToMap(view, DataContext.newInstance().withDisabledValues(true).withEmptyValues(false));
237        
238        return _getRepeaterEntries(dataToMap, repeaterDefinition.getPath());
239    }
240    
241    @SuppressWarnings("unchecked")
242    private List<Map<String, Object>> _getRepeaterEntries(Map<String, Object> data, String path)
243    {
244        String[] pathSegments = StringUtils.split(path, ModelItem.ITEM_PATH_SEPARATOR);
245        if (pathSegments.length == 1)
246        {
247            return (List<Map<String, Object>>) data.get(pathSegments[0]);
248        }
249        else
250        {
251            Map<String, Object> subData = (Map<String, Object>) data.get(pathSegments[0]);
252            String remainPath = StringUtils.join(pathSegments, ModelItem.ITEM_PATH_SEPARATOR, 1, pathSegments.length);
253            return _getRepeaterEntries(subData, remainPath);
254        }
255    }
256    
257    private Set<String> _getDisabledConditionPaths(ModelItem modelItem)
258    {
259        DisableConditions<? extends DisableCondition> disableConditions = modelItem.getDisableConditions();
260        if (disableConditions != null)
261        {
262            Set<String> disableConditionPaths = disableConditions.getConditions()
263                    .stream()
264                    .map(c -> ModelHelper.getDisableConditionAbsolutePath(c, modelItem.getPath()))
265                    .collect(Collectors.toSet());
266            
267            if (modelItem instanceof ModelItemGroup group)
268            {
269                for (ModelItem childModelItem : group.getChildren())
270                {
271                    disableConditionPaths.addAll(_getDisabledConditionPaths(childModelItem));
272                }
273            }
274            
275            return disableConditionPaths;
276        }
277        return Set.of();
278    }
279}