001/* 002 * Copyright 2022 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.plugins.forms.helper; 017 018import java.util.ArrayList; 019import java.util.Arrays; 020import java.util.HashMap; 021import java.util.LinkedHashMap; 022import java.util.List; 023import java.util.Map; 024import java.util.Map.Entry; 025import java.util.stream.Collectors; 026 027import org.apache.avalon.framework.component.Component; 028import org.apache.avalon.framework.service.ServiceException; 029import org.apache.avalon.framework.service.ServiceManager; 030import org.apache.avalon.framework.service.Serviceable; 031import org.apache.commons.lang3.StringUtils; 032 033import org.ametys.core.right.RightManager; 034import org.ametys.core.right.RightManager.RightResult; 035import org.ametys.core.ui.Callable; 036import org.ametys.plugins.forms.question.FormQuestionType; 037import org.ametys.plugins.forms.question.sources.AbstractSourceType; 038import org.ametys.plugins.forms.question.sources.ChoiceOption; 039import org.ametys.plugins.forms.question.sources.ChoiceSourceType; 040import org.ametys.plugins.forms.question.types.CheckBoxQuestionType; 041import org.ametys.plugins.forms.question.types.ChoicesListQuestionType; 042import org.ametys.plugins.forms.question.types.MatrixQuestionType; 043import org.ametys.plugins.forms.repository.Form; 044import org.ametys.plugins.forms.repository.FormEntry; 045import org.ametys.plugins.forms.repository.FormQuestion; 046import org.ametys.plugins.forms.repository.type.Matrix; 047import org.ametys.plugins.repository.AmetysObject; 048import org.ametys.plugins.repository.AmetysObjectIterable; 049import org.ametys.plugins.repository.AmetysObjectResolver; 050import org.ametys.runtime.i18n.I18nizableText; 051import org.ametys.runtime.plugin.component.AbstractLogEnabled; 052 053/** 054 * The helper to handle admin emails 055 */ 056public class FormStatisticsHelper extends AbstractLogEnabled implements Serviceable, Component 057{ 058 /** Avalon ROLE. */ 059 public static final String ROLE = FormStatisticsHelper.class.getName(); 060 061 /** The Ametys Object resolver */ 062 protected AmetysObjectResolver _resolver; 063 064 /** The right manager */ 065 protected RightManager _rightManager; 066 067 public void service(ServiceManager manager) throws ServiceException 068 { 069 _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE); 070 _rightManager = (RightManager) manager.lookup(RightManager.ROLE); 071 } 072 073 /** 074 * Generates statistics on each question of a form. 075 * @param id The form id 076 * @return A map containing the statistics 077 */ 078 @Callable 079 public Map<String, Object> getStatistics(String id) 080 { 081 Form form = _resolver.resolveById(id); 082 083 if (_rightManager.currentUserHasRight("Form_Entries_Rights_Data", form) != RightResult.RIGHT_ALLOW) 084 { 085 return Map.of("message", "not-allowed"); 086 } 087 088 return _getStatistics(form); 089 } 090 091 /** 092 * Generate statistics of a mini-survey 093 * No rights will be checked. Only a check that it is indeed a mini-survey will be done. 094 * @param id the form id 095 * @return a JSON map of the statistics 096 */ 097 public Map<String, Object> getMiniSurveyStatistics(String id) 098 { 099 Form form = _resolver.resolveById(id); 100 if (form.isMiniSurvey()) 101 { 102 return _getStatistics(form); 103 } 104 else 105 { 106 return Map.of("error", "not-a-mini-survey"); 107 } 108 } 109 110 /** 111 * Compute the statistics for each questions of the form 112 * @param form the form 113 * @return A JSON map representing the statistics 114 */ 115 protected Map<String, Object> _getStatistics(Form form) 116 { 117 Map<String, Object> statistics = new HashMap<>(); 118 119 statistics.put("id", form.getId()); 120 statistics.put("title", form.getTitle()); 121 statistics.put("nbEntries", form.getEntries().size()); 122 statistics.put("questions", getStatsToArray(form)); 123 124 return statistics; 125 } 126 127 /** 128 * Create a map with count of all answers per question 129 * @param form current form 130 * @return the statsMap 131 */ 132 public Map<String, Map<String, Map<String, Object>>> getStatsMap(Form form) 133 { 134 Map<String, Map<String, Map<String, Object>>> statsMap = new LinkedHashMap<>(); 135 List<FormQuestion> questions = form.getQuestions() 136 .stream() 137 .filter(this::_displayField) 138 .collect(Collectors.toList()); 139 140 List<FormEntry> entries = form.getEntries(); 141 142 for (FormQuestion question : questions) 143 { 144 Map<String, Map<String, Object>> questionValues = new LinkedHashMap<>(); 145 statsMap.put(question.getNameForForm(), questionValues); 146 147 if (question.getType() instanceof MatrixQuestionType) 148 { 149 _dispatchMatrixStats(entries, question, questionValues); 150 } 151 else if (question.getType() instanceof ChoicesListQuestionType type) 152 { 153 if (type.getSourceType(question).remoteData()) 154 { 155 _dispatchChoicesWithRemoteDataStats(form, question, questionValues); 156 } 157 else 158 { 159 _dispatchChoicesStats(form, question, questionValues); 160 } 161 } 162 else if (question.getType() instanceof CheckBoxQuestionType) 163 { 164 _dispatchBooleanStats(entries, question, questionValues); 165 } 166 else 167 { 168 _dispatchStats(entries, question, questionValues); 169 } 170 } 171 172 return statsMap; 173 } 174 175 /** 176 * Transforms the statistics map into an array with some info. 177 * @param form The form 178 * @return A list of statistics. 179 */ 180 public List<Map<String, Object>> getStatsToArray (Form form) 181 { 182 Map<String, Map<String, Map<String, Object>>> stats = getStatsMap(form); 183 184 List<Map<String, Object>> result = new ArrayList<>(); 185 186 for (String questionNameForForm : stats.keySet()) 187 { 188 Map<String, Object> questionMap = new HashMap<>(); 189 190 FormQuestion question = form.getQuestion(questionNameForForm); 191 Map<String, Map<String, Object>> questionStats = stats.get(questionNameForForm); 192 193 questionMap.put("id", questionNameForForm); 194 questionMap.put("title", question.getTitle()); 195 questionMap.put("type", question.getType().getStorageType(question)); 196 questionMap.put("typeId", question.getType().getId()); 197 questionMap.put("mandatory", question.isMandatory()); 198 199 List<Object> options = new ArrayList<>(); 200 for (String optionId : questionStats.keySet()) 201 { 202 Map<String, Object> option = new HashMap<>(); 203 204 option.put("id", optionId); 205 206 if (question.getType() instanceof MatrixQuestionType) 207 { 208 MatrixQuestionType type = (MatrixQuestionType) question.getType(); 209 option.put("label", type.getRows(question).get(optionId)); 210 } 211 212 questionStats.get(optionId).entrySet(); 213 List<Object> choices = new ArrayList<>(); 214 for (Entry<String, Object> choice : questionStats.get(optionId).entrySet()) 215 { 216 Map<String, Object> choiceMap = new HashMap<>(); 217 218 String choiceId = choice.getKey(); 219 Object choiceOb = choice.getValue(); 220 choiceMap.put("value", choiceId); 221 choiceMap.put("label", choiceOb instanceof Option ? ((Option) choiceOb).label() : choiceOb); 222 223 if (question.getType() instanceof MatrixQuestionType) 224 { 225 MatrixQuestionType type = (MatrixQuestionType) question.getType(); 226 choiceMap.put("label", type.getColumns(question).get(choiceId)); 227 } 228 229 230 choiceMap.put("count", choiceOb instanceof Option ? ((Option) choiceOb).count() : choiceOb); 231 232 choices.add(choiceMap); 233 } 234 option.put("choices", choices); 235 236 options.add(option); 237 } 238 questionMap.put("options", options); 239 240 result.add(questionMap); 241 } 242 243 return result; 244 } 245 246 /** 247 * Dispatch matrix stats 248 * @param entries the entries 249 * @param question the question 250 * @param questionValues the values to fill 251 */ 252 protected void _dispatchMatrixStats(List<FormEntry> entries, FormQuestion question, Map<String, Map<String, Object>> questionValues) 253 { 254 MatrixQuestionType matrixType = (MatrixQuestionType) question.getType(); 255 Map<String, String> rows = matrixType.getRows(question); 256 if (rows != null) 257 { 258 for (String option : rows.keySet()) 259 { 260 Map<String, Object> values = new LinkedHashMap<>(); 261 questionValues.put(option, values); 262 263 Map<String, String> columns = matrixType.getColumns(question); 264 if (columns != null) 265 { 266 for (String column : columns.keySet()) 267 { 268 values.put(column, 0); 269 _setOptionCount(question.getNameForForm(), entries, values, option, column); 270 } 271 } 272 } 273 } 274 } 275 276 private void _setOptionCount(String questionId, List<FormEntry> entries, Map<String, Object> values, String rowValue, String columnValue) 277 { 278 int columnCount = (int) values.get(columnValue); 279 for (FormEntry entry : entries) 280 { 281 Matrix matrix = entry.getValue(questionId); 282 if (matrix != null) 283 { 284 List<String> options = matrix.get(rowValue); 285 if (options != null && options.contains(columnValue)) 286 { 287 columnCount++; 288 } 289 } 290 } 291 values.put(columnValue, columnCount); 292 } 293 294 /** 295 * Dispatch choices list stats 296 * @param form the form 297 * @param question the question 298 * @param questionValues the values to fill 299 */ 300 protected void _dispatchChoicesStats(Form form, FormQuestion question, Map<String, Map<String, Object>> questionValues) 301 { 302 Map<String, Object> values = new LinkedHashMap<>(); 303 questionValues.put("values", values); 304 305 ChoicesListQuestionType type = (ChoicesListQuestionType) question.getType(); 306 ChoiceSourceType sourceType = type.getSourceType(question); 307 Map<ChoiceOption, I18nizableText> options; 308 try 309 { 310 String uuid = form.getNode().getIdentifier(); 311 312 Map<String, Object> enumParam = new HashMap<>(); 313 enumParam.put(AbstractSourceType.QUESTION_PARAM_KEY, question); 314 options = sourceType.getTypedEntries(enumParam); 315 316 for (ChoiceOption option : options.keySet()) 317 { 318 String optionValue = (String) option.getValue(); 319 String xpathQuery = "//element(*, ametys:form)[@jcr:uuid='" + uuid + "']//element(*, ametys:form-entry)[@ametys:" + question.getNameForForm() + " = '" + optionValue + "']"; 320 long countOption; 321 try (AmetysObjectIterable<AmetysObject> results = _resolver.query(xpathQuery)) 322 { 323 countOption = results.getSize(); 324 } 325 Option choiceAttributes = new Option(options.get(option).getLabel(), countOption); 326 values.put(optionValue, choiceAttributes); 327 } 328 329 if (type.hasOtherOption(question)) 330 { 331 // Add other option 332 String xpathQuery = "//element(*, ametys:form)[@jcr:uuid='" + uuid + "']//element(*, ametys:form-entry)[@ametys:" + ChoicesListQuestionType.OTHER_PREFIX_DATA_NAME + question.getNameForForm() + " != '']"; 333 long countOtherOption; 334 try (AmetysObjectIterable<AmetysObject> results = _resolver.query(xpathQuery)) 335 { 336 countOtherOption = results.getSize(); 337 } 338 Option choiceAttributes = new Option(ChoicesListQuestionType.OTHER_OPTION_VALUE, countOtherOption); 339 values.put(ChoicesListQuestionType.OTHER_OPTION_VALUE, choiceAttributes); 340 } 341 } 342 catch (Exception e) 343 { 344 getLogger().error("An error occurred while trying to get choices options for question " + question.getId(), e); 345 } 346 } 347 348 /** 349 * Dispatch choices list with remote data stats 350 * @param form the form 351 * @param question the question 352 * @param questionValues the values to fill 353 */ 354 protected void _dispatchChoicesWithRemoteDataStats(Form form, FormQuestion question, Map<String, Map<String, Object>> questionValues) 355 { 356 Map<String, Object> values = new LinkedHashMap<>(); 357 questionValues.put("values", values); 358 359 ChoicesListQuestionType type = (ChoicesListQuestionType) question.getType(); 360 ChoiceSourceType sourceType = type.getSourceType(question); 361 362 try 363 { 364 long otherCount = 0; 365 String nameForForm = question.getNameForForm(); 366 Map<Object, Long> stats = new LinkedHashMap<>(); 367 for (FormEntry entry : form.getEntries()) 368 { 369 if (entry.getValue(nameForForm) != null) 370 { 371 @SuppressWarnings("cast") 372 List<Object> vals = entry.isMultiple(nameForForm) 373 ? Arrays.asList(entry.getValue(nameForForm)) 374 : List.of((Object) entry.getValue(nameForForm)); // Need to cast in object because List.of want object and entry.getValue return typed value as string for exemple 375 for (Object value : vals) 376 { 377 Long count = stats.getOrDefault(value, 0L); 378 stats.put(value, count + 1); 379 } 380 } 381 382 if (type.hasOtherOption(question) && StringUtils.isNotBlank(entry.getValue(ChoicesListQuestionType.OTHER_PREFIX_DATA_NAME + nameForForm))) 383 { 384 otherCount++; 385 } 386 } 387 388 Map<String, Object> enumParam = new HashMap<>(); 389 enumParam.put(AbstractSourceType.QUESTION_PARAM_KEY, question); 390 391 for (Object value : stats.keySet()) 392 { 393 I18nizableText entry = sourceType.getEntry(new ChoiceOption(value), enumParam); 394 if (entry != null) 395 { 396 Option choiceAttributes = new Option(entry.getLabel(), stats.get(value)); 397 values.put(value.toString(), choiceAttributes); 398 } 399 } 400 401 if (otherCount > 0) 402 { 403 Option choiceAttributes = new Option(ChoicesListQuestionType.OTHER_OPTION_VALUE, otherCount); 404 values.put(ChoicesListQuestionType.OTHER_OPTION_VALUE, choiceAttributes); 405 } 406 } 407 catch (Exception e) 408 { 409 getLogger().error("An error occurred while trying to get choices options for question " + question.getId(), e); 410 } 411 } 412 413 /** 414 * Dispatch boolean stats 415 * @param entries the entries 416 * @param question the question 417 * @param questionValues the values to fill 418 */ 419 protected void _dispatchBooleanStats(List<FormEntry> entries, FormQuestion question, Map<String, Map<String, Object>> questionValues) 420 { 421 Map<String, Object> values = new LinkedHashMap<>(); 422 questionValues.put("values", values); 423 424 int totalTrue = _getTrueCount(question.getNameForForm(), entries); 425 values.put("true", totalTrue); 426 values.put("false", entries.size() - totalTrue); 427 } 428 429 private int _getTrueCount(String questionId, List<FormEntry> entries) 430 { 431 int trueCount = 0; 432 for (FormEntry entry : entries) 433 { 434 Boolean value = entry.getValue(questionId); 435 if (value != null && value) 436 { 437 trueCount++; 438 } 439 } 440 return trueCount; 441 } 442 443 /** 444 * Dispatch default stats 445 * @param entries the entries 446 * @param question the question 447 * @param questionValues the values to fill 448 */ 449 protected void _dispatchStats(List<FormEntry> entries, FormQuestion question, Map<String, Map<String, Object>> questionValues) 450 { 451 Map<String, Object> values = new LinkedHashMap<>(); 452 questionValues.put("values", values); 453 454 int totalAnswered = _getAnsweredCount(question.getNameForForm(), entries); 455 values.put("answered", totalAnswered); 456 values.put("empty", entries.size() - totalAnswered); 457 } 458 459 private int _getAnsweredCount(String questionId, List<FormEntry> entries) 460 { 461 int totalAnswered = 0; 462 for (FormEntry entry : entries) 463 { 464 if (entry.hasValue(questionId)) 465 { 466 totalAnswered++; 467 } 468 } 469 return totalAnswered; 470 } 471 472 private boolean _displayField(FormQuestion question) 473 { 474 FormQuestionType type = question.getType(); 475 return type.canBeAnsweredByUser(question); 476 } 477 478 /** 479 * Record representing a choice list option 480 * @param label the label 481 * @param count the number of time the option is selected 482 */ 483 public record Option(String label, long count) { /* empty */ } 484}