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}