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