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}