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}