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