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.validator;
017
018import java.util.ArrayList;
019import java.util.HashMap;
020import java.util.List;
021import java.util.Map;
022import java.util.Optional;
023import java.util.stream.Collectors;
024import java.util.stream.Stream;
025
026import org.apache.avalon.framework.configuration.Configurable;
027import org.apache.avalon.framework.configuration.Configuration;
028import org.apache.avalon.framework.configuration.ConfigurationException;
029import org.apache.avalon.framework.service.ServiceException;
030import org.apache.avalon.framework.service.ServiceManager;
031import org.apache.avalon.framework.service.Serviceable;
032import org.apache.commons.lang3.StringUtils;
033
034import org.ametys.cms.contenttype.ContentType;
035import org.ametys.cms.contenttype.ContentValidator;
036import org.ametys.cms.contenttype.validation.AbstractContentValidator;
037import org.ametys.cms.data.holder.group.IndexableRepeater;
038import org.ametys.cms.repository.Content;
039import org.ametys.core.util.I18nUtils;
040import org.ametys.plugins.repository.data.holder.values.SynchronizableRepeater;
041import org.ametys.plugins.repository.data.holder.values.SynchronizableValue;
042import org.ametys.plugins.repository.data.holder.values.UntouchedValue;
043import org.ametys.plugins.repository.model.RepeaterDefinition;
044import org.ametys.runtime.i18n.I18nizableText;
045import org.ametys.runtime.i18n.I18nizableTextParameter;
046import org.ametys.runtime.model.ElementDefinition;
047import org.ametys.runtime.model.ModelItem;
048import org.ametys.runtime.model.ModelItemGroup;
049import org.ametys.runtime.model.View;
050import org.ametys.runtime.model.type.ModelItemTypeConstants;
051import org.ametys.runtime.parameter.ValidationResult;
052
053/**
054 * This implementation of {@link ContentValidator} validates the content by checking the distribution of weights in a repeater.
055 * For each entry of repeater, check if the sum of the 'weight' values is equal or lower than 100.
056 * 
057 * The repater can be linked to a percentage attribute. If the percentage attribute is set to false, the validation is skipped (means that weights are not expressed as a percentage).
058 * 
059 * The repeater and weights are identified in validator's configuration by the attribute path as follows:
060 * <repeater path="path/to/repeater" weightAttribute="weight"/>
061 * 
062 * The validator can validate multiple weight attributes in the same repeater.
063 * To do so, multiple <attribute path="path/to/weight"/> can be defined inside the validator configuration.
064 * 
065 * The percentage attribute is identified in validator's configuration by the attribute path as follows:
066 * <percentage path="path/to/attribute/percentage"/>
067 * 
068 */
069public class RepeaterDistributionValidator extends AbstractContentValidator implements Configurable, Serviceable
070{
071    private String _repeaterPath;
072    private String _percentagePath;
073    private List<String> _attributePath;
074    private RepeaterDefinition _repeaterDef;
075    
076    private I18nUtils _i18nUtils;
077
078    @Override
079    public void configure(Configuration configuration) throws ConfigurationException
080    {
081        _repeaterPath = configuration.getChild("repeater").getAttribute("path");
082        _percentagePath = configuration.getChild("percentage", true).getAttribute("path", null);
083        
084        _attributePath = new ArrayList<>();
085        for (Configuration attrConf : configuration.getChildren("attribute"))
086        {
087            _attributePath.add(attrConf.getAttribute("path"));
088        }
089    }
090    
091    public void service(ServiceManager manager) throws ServiceException
092    {
093        _i18nUtils = (I18nUtils) manager.lookup(I18nUtils.ROLE);
094    }
095    
096    @Override
097    public void initialize() throws ConfigurationException
098    {
099        super.initialize();
100        
101        ContentType contentType = getContentType();
102        if (contentType.hasModelItem(_repeaterPath) && contentType.getModelItem(_repeaterPath) instanceof RepeaterDefinition repeaterDef)
103        {
104            _repeaterDef = repeaterDef;
105        }
106        else
107        {
108            throw new ConfigurationException("The repeater path '" + _repeaterPath + "' does not point to a valid repeater in content type '" + contentType.getId() + "' for RepeaterDistributionValidator validator.");
109        }
110        
111        if (_attributePath.isEmpty())
112        {
113            throw new ConfigurationException("At least one weight attribute must be defined for RepeaterDistributionValidator validator.");
114        }
115        
116        for (String attributePath : _attributePath)
117        {
118            if (!repeaterDef.hasModelItem(attributePath) || !(repeaterDef.getModelItem(attributePath) instanceof ElementDefinition elmtDef) || !elmtDef.getType().getId().equals(ModelItemTypeConstants.DOUBLE_TYPE_ID))
119            {
120                throw new ConfigurationException("The weight attribute '" + attributePath + "' does not point to a valid double attribute in the repeater '" + _repeaterPath + "' for RepeaterDistributionValidator validator.");
121            }
122        }
123        
124        if (_percentagePath != null && (!contentType.hasModelItem(_percentagePath) || !(contentType.getModelItem(_percentagePath) instanceof ElementDefinition elmtDef) || !elmtDef.getType().getId().equals(ModelItemTypeConstants.BOOLEAN_TYPE_ID)))
125        {
126            throw new ConfigurationException("The percentage path '" + _percentagePath + "' does not point to a valid boolean attribute in content type '" + contentType.getId() + "' for RepeaterDistributionValidator validator.");
127        }
128    }
129    
130    public ValidationResult validate(Content content)
131    {
132        ValidationResult result = new ValidationResult();
133        
134        if (_percentagePath == null || Boolean.TRUE.equals(content.getValue(_percentagePath)))
135        {
136            for (String attributePath : _attributePath)
137            {
138                double sum = Optional.ofNullable(content.getRepeater(_repeaterPath))
139                        .map(IndexableRepeater::getEntries)
140                        .map(List::stream)
141                        .orElseGet(Stream::of)
142                        .mapToDouble(entry -> (Double) entry.getValue(attributePath))
143                        .sum();
144                
145                if (sum > 100.0)
146                {
147                    Map<String, I18nizableTextParameter> i18nParams = new HashMap<>();
148                    i18nParams.put("sum", new I18nizableText(String.valueOf(sum)));
149                    i18nParams.put("repeaterPath", new I18nizableText(_getRepeaterReadablePath()));
150                    i18nParams.put("attributeLabel", new I18nizableText(_getAttributeLabel(attributePath)));
151                    result.addError(new I18nizableText("plugin.odf", "PLUGINS_ODF_CONTENT_VALIDATOR_REPEATER_DISTRIBUTION_WITH_PERCENTAGE_ERROR", i18nParams));
152                }
153            }
154        }
155        
156        return result;
157    }
158    
159    public ValidationResult validate(Content content, Map<String, Object> values, View view)
160    {
161        ValidationResult result = new ValidationResult();
162        
163        boolean weightInPercentage = _percentagePath == null; // Default to true if no percentage attribute is defined
164        Optional<SynchronizableValue> percentageValue = _percentagePath != null ? _getValue(values, _percentagePath) : Optional.empty();
165        
166        if (percentageValue.isPresent())
167        {
168            weightInPercentage = (Boolean) percentageValue.get().getValue(Optional.ofNullable(percentageValue.get().getExternalizableStatus()));
169        }
170        
171        if (weightInPercentage)
172        {
173            Optional<SynchronizableRepeater> repeater = _getRepeater(values, _repeaterPath);
174            
175            if (repeater.isPresent())
176            {
177                List<Map<String, Object>> repeaterEntries =  repeater.get().getEntries();
178
179                if (!repeaterEntries.isEmpty())
180                {
181                    for (String attributePath : _attributePath)
182                    {
183                        Double sum = repeaterEntries.stream()
184                                .map(entry -> entry.get(attributePath))
185                                .filter(SynchronizableValue.class::isInstance)
186                                .map(SynchronizableValue.class::cast)
187                                .map(syncValue -> (Double) syncValue.getValue(Optional.ofNullable(syncValue.getExternalizableStatus())))
188                                .reduce(0.0, Double::sum);
189                            
190                        if (sum > 100.0)
191                        {
192                            Map<String, I18nizableTextParameter> i18nParams = new HashMap<>();
193                            i18nParams.put("sum", new I18nizableText(String.valueOf(sum)));
194                            i18nParams.put("repeaterPath", new I18nizableText(_getRepeaterReadablePath()));
195                            i18nParams.put("attributeLabel", new I18nizableText(_getAttributeLabel(attributePath)));
196                            result.addError(new I18nizableText("plugin.odf-orientation-data", "PLUGINS_ODF_CONTENT_VALIDATOR_REPEATER_DISTRIBUTION_WITH_PERCENTAGE_ERROR", i18nParams));
197                        }
198                    }
199                    
200                }
201            }
202        }
203        
204        return result;
205    }
206    
207    private String _getRepeaterReadablePath()
208    {
209        List<I18nizableText> labels = new ArrayList<>();
210        labels.add(_repeaterDef.getLabel());
211        
212        ModelItemGroup parent = _repeaterDef.getParent();
213        while (parent != null)
214        {
215            labels.add(parent.getLabel());
216            parent = parent.getParent();
217        }
218        
219        return labels.reversed()
220            .stream()
221            .map(label -> _i18nUtils.translate(label))
222            .collect(Collectors.joining(" > "));
223    }
224    
225    private String _getAttributeLabel(String attributePath)
226    {
227        I18nizableText label = _repeaterDef.getModelItem(attributePath).getLabel();
228        return _i18nUtils.translate(label);
229    }
230    
231    @SuppressWarnings("unchecked")
232    private Optional<SynchronizableRepeater> _getRepeater(Map<String, Object> values, String repeaterPath)
233    {
234        // If current map is not valued, return an empty Optional
235        if (values == null)
236        {
237            return Optional.empty();
238        }
239        
240        String firstSegment = StringUtils.substringBefore(repeaterPath, ModelItem.ITEM_PATH_SEPARATOR);
241        
242        Object value = values.get(firstSegment);
243        
244        // If value is not valued or disabled
245        if (value == null || value instanceof UntouchedValue)
246        {
247            return Optional.empty();
248        }
249        
250        // Not the last element
251        if (value instanceof Map map)
252        {
253            return _getRepeater(map, StringUtils.substringAfter(repeaterPath, ModelItem.ITEM_PATH_SEPARATOR));
254        }
255        
256        // It is the last segment, return the value as optional
257        if (value instanceof SynchronizableRepeater syncRepeater)
258        {
259            return Optional.ofNullable(syncRepeater);
260        }
261        
262        // Value is not a synchronizable repeater
263        throw new IllegalArgumentException("Expected value is not a synchronizable repeater");
264    }
265    
266    @SuppressWarnings("unchecked")
267    private Optional<SynchronizableValue> _getValue(Map<String, Object> values, String attributePath)
268    {
269        // If current map is not valued, return an empty Optional
270        if (values == null)
271        {
272            return Optional.empty();
273        }
274        
275        String firstSegment = StringUtils.substringBefore(attributePath, ModelItem.ITEM_PATH_SEPARATOR);
276        
277        Object value = values.get(firstSegment);
278        
279        // If value is not valued or disabled
280        if (value == null || value instanceof UntouchedValue)
281        {
282            return Optional.empty();
283        }
284        
285        // Not the last element
286        if (value instanceof Map map)
287        {
288            return _getValue(map, StringUtils.substringAfter(attributePath, ModelItem.ITEM_PATH_SEPARATOR));
289        }
290        
291        // It is the last segment, return the value as optional
292        if (value instanceof SynchronizableValue syncValue)
293        {
294            return Optional.ofNullable(syncValue);
295        }
296        
297        // Value is not a synchronizable repeater
298        throw new IllegalArgumentException("Expected value is not a synchronizable value");
299    }
300}