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