001/*
002 *  Copyright 2021 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 */
016
017package org.ametys.plugins.forms.dao;
018
019import java.util.ArrayList;
020import java.util.Collection;
021import java.util.HashMap;
022import java.util.List;
023import java.util.Map;
024
025import org.apache.avalon.framework.component.Component;
026import org.apache.avalon.framework.context.Context;
027import org.apache.avalon.framework.context.ContextException;
028import org.apache.avalon.framework.context.Contextualizable;
029import org.apache.avalon.framework.service.ServiceException;
030import org.apache.avalon.framework.service.ServiceManager;
031import org.apache.avalon.framework.service.Serviceable;
032import org.apache.cocoon.Constants;
033import org.apache.cocoon.ProcessingException;
034import org.apache.commons.lang.StringUtils;
035
036import org.ametys.core.observation.Event;
037import org.ametys.core.observation.ObservationManager;
038import org.ametys.core.right.RightManager;
039import org.ametys.core.right.RightManager.RightResult;
040import org.ametys.core.ui.Callable;
041import org.ametys.core.upload.UploadManager;
042import org.ametys.core.user.CurrentUserProvider;
043import org.ametys.core.user.UserIdentity;
044import org.ametys.core.util.I18nUtils;
045import org.ametys.core.util.JSONUtils;
046import org.ametys.plugins.forms.FormEvents;
047import org.ametys.plugins.forms.question.FormQuestionType;
048import org.ametys.plugins.forms.question.FormQuestionTypeExtensionPoint;
049import org.ametys.plugins.forms.question.sources.AbstractSourceType;
050import org.ametys.plugins.forms.question.sources.ChoiceOption;
051import org.ametys.plugins.forms.question.sources.ChoiceSourceType;
052import org.ametys.plugins.forms.question.sources.ChoiceSourceTypeExtensionPoint;
053import org.ametys.plugins.forms.question.types.ChoicesListQuestionType;
054import org.ametys.plugins.forms.repository.Form;
055import org.ametys.plugins.forms.repository.FormPage;
056import org.ametys.plugins.forms.repository.FormPageRule;
057import org.ametys.plugins.forms.repository.FormPageRule.PageRuleType;
058import org.ametys.plugins.forms.repository.FormQuestion;
059import org.ametys.plugins.repository.AmetysObject;
060import org.ametys.plugins.repository.AmetysObjectResolver;
061import org.ametys.plugins.repository.UnknownAmetysObjectException;
062import org.ametys.plugins.repository.jcr.NameHelper;
063import org.ametys.runtime.authentication.AccessDeniedException;
064import org.ametys.runtime.i18n.I18nizableText;
065import org.ametys.runtime.model.DefinitionContext;
066import org.ametys.runtime.model.ElementDefinition;
067import org.ametys.runtime.model.Model;
068import org.ametys.runtime.model.ModelItem;
069import org.ametys.runtime.model.View;
070import org.ametys.runtime.plugin.component.AbstractLogEnabled;
071import org.ametys.web.parameters.ParametersManager;
072
073/** DAO for manipulating form questions */
074public class FormQuestionDAO extends AbstractLogEnabled implements Serviceable, Component, Contextualizable
075{
076    /** The Avalon role */
077    public static final String ROLE = FormQuestionDAO.class.getName();
078
079    /** Name for rules root jcr node */
080    public static final String RULES_ROOT = "ametys-internal:form-page-rules";
081    
082    /** The String representing the type of a form question node */
083    protected static final String FORM_QUESTION_TYPE = "question";
084    
085    /** Ametys object resolver. */
086    protected AmetysObjectResolver _resolver;
087    /** Observer manager. */
088    protected ObservationManager _observationManager;
089    /** The current user provider. */
090    protected CurrentUserProvider _currentUserProvider;
091    /** Manager for retrieving uploaded files */
092    protected UploadManager _uploadManager;
093    /** JSON helper */
094    protected JSONUtils _jsonUtils;
095    /** I18n Utils */
096    protected I18nUtils _i18nUtils;
097    /** The form question type extension point */
098    protected FormQuestionTypeExtensionPoint _formQuestionTypeExtensionPoint;
099    /** The parameters manager */
100    protected ParametersManager _parametersManager;
101    /** The Avalon context */
102    protected Context _context;
103    /** The cocoon context */
104    protected org.apache.cocoon.environment.Context _cocoonContext;
105    /** The choice source type extension point */
106    protected ChoiceSourceTypeExtensionPoint _choiceSourceTypeExtensionPoint;
107    /**The form DAO */
108    protected FormDAO _formDAO;
109    /** The right manager */
110    protected RightManager _rightManager;
111    
112    @Override
113    public void service(ServiceManager serviceManager) throws ServiceException
114    {
115        _resolver = (AmetysObjectResolver) serviceManager.lookup(AmetysObjectResolver.ROLE);
116        _observationManager = (ObservationManager) serviceManager.lookup(ObservationManager.ROLE);
117        _parametersManager = (ParametersManager) serviceManager.lookup(ParametersManager.ROLE);
118        _currentUserProvider = (CurrentUserProvider) serviceManager.lookup(CurrentUserProvider.ROLE);
119        _uploadManager = (UploadManager) serviceManager.lookup(UploadManager.ROLE);
120        _jsonUtils = (JSONUtils) serviceManager.lookup(JSONUtils.ROLE);
121        _i18nUtils = (I18nUtils) serviceManager.lookup(I18nUtils.ROLE);
122        _formQuestionTypeExtensionPoint = (FormQuestionTypeExtensionPoint) serviceManager.lookup(FormQuestionTypeExtensionPoint.ROLE);
123        _parametersManager = (ParametersManager) serviceManager.lookup(ParametersManager.ROLE);
124        _formDAO = (FormDAO) serviceManager.lookup(FormDAO.ROLE);
125        _rightManager = (RightManager) serviceManager.lookup(RightManager.ROLE);
126    }
127    
128    @Override
129    public void contextualize(Context context) throws ContextException
130    {
131        _context = context;
132        _cocoonContext = (org.apache.cocoon.environment.Context) context.get(Constants.CONTEXT_ENVIRONMENT_CONTEXT);
133    }
134    
135    /**
136     * Check rights for a form element as ametys object
137     * @param ao the ametys object
138     */
139    protected void _checkRights(AmetysObject ao)
140    {
141        if (_rightManager.hasRight(_currentUserProvider.getUser(), FormDAO.HANDLE_FORMS_RIGHT_ID, ao) != RightResult.RIGHT_ALLOW)
142        {
143            throw new AccessDeniedException("User '" + _currentUserProvider.getUser() + "' tried to handle forms without convenient right [" + FormDAO.HANDLE_FORMS_RIGHT_ID + "]");
144        }
145    }
146    
147    /**
148     * Provides the current user.
149     * @return the user which cannot be <code>null</code>.
150     */
151    protected UserIdentity _getCurrentUser()
152    {      
153        return _currentUserProvider.getUser();
154    }
155    
156    /**
157     * Gets properties of a form question
158     * @param id The id of the form question
159     * @return The properties
160     */
161    @Callable
162    public Map<String, Object> getQuestionProperties (String id)
163    {
164        try
165        {
166            FormQuestion question = _resolver.resolveById(id);
167            return getQuestionProperties(question);
168        }
169        catch (UnknownAmetysObjectException e)
170        {
171            getLogger().warn("Can't find question with id: {}. It probably has just been deleted", id, e);
172            Map<String, Object> infos = new HashMap<>();
173            infos.put("id", id);
174            return infos;
175        }
176    }
177    
178    /**
179     * Gets properties of a form question
180     * @param question The form question
181     * @return The properties
182     */
183    public Map<String, Object> getQuestionProperties (FormQuestion question)
184    {
185        Map<String, Object> properties = new HashMap<>();
186        
187        properties.put("id", question.getId());
188        properties.put("nameForForm", question.getNameForForm());
189        properties.put("type", FORM_QUESTION_TYPE); 
190        properties.put("questionType", question.getType().getId());
191        properties.put("title", question.getTitle());
192        properties.put("pageId", question.getFormPage().getId());
193        properties.put("formId", question.getForm().getId());
194        properties.put("iconGlyph", question.getType().getIconGlyph());
195        properties.put("typeLabel", question.getType().getLabel());
196        properties.put("formHasEntries", !question.getForm().getEntries().isEmpty());
197        
198        boolean hasTerminalRule = _hasTerminalRule(question);
199        List<String> questionTitlesWithRule = _getQuestionTitlesWithRule(question);
200        List<String> pageTitlesWithRule = _getPageTitlesWithRule(question);
201        
202        properties.put("hasTerminalRule", hasTerminalRule);
203        properties.put("pageTitlesWithRule", pageTitlesWithRule);
204        properties.put("questionTitlesWithRule", questionTitlesWithRule);
205        properties.put("hasRule", hasTerminalRule || !pageTitlesWithRule.isEmpty() || !questionTitlesWithRule.isEmpty());
206        properties.put("hasChildren", false);
207        UserIdentity currentUser = _currentUserProvider.getUser();
208        properties.put("canWrite", _formDAO.hasWriteRightOnForm(currentUser, question.getForm()));
209        properties.put("isConfigured", question.getType().isQuestionConfigured(question));
210        
211        if (question.getType() instanceof ChoicesListQuestionType type)
212        {
213            Map<String, I18nizableText> options = type.getOptions(question);
214            if (options.size() > 0)
215            {
216                properties.put("otherOption", type.hasOtherOption(question));
217                properties.put("options", options);
218            }
219        }
220        
221        return properties;
222    }
223    
224    private boolean _hasTerminalRule(FormQuestion question)
225    {
226        return question.getPageRules()
227                .stream()
228                .map(FormPageRule::getType)
229                .filter(t -> t == PageRuleType.FINISH)
230                .findAny()
231                .isPresent();
232    }
233
234    /**
235     * Get the question titles having rule concerning the given question
236     * @param question the question
237     * @return the list of question titles
238     */
239    protected List<String> _getQuestionTitlesWithRule(FormQuestion question)
240    {
241        return question.getForm()
242            .getQuestionsRule(question.getId())
243            .keySet()
244            .stream()
245            .map(FormQuestion::getTitle)
246            .toList();
247    }
248    
249    /**
250     * Get the page titles having rule concerning the given question
251     * @param question the question
252     * @return the list of page titles
253     */
254    protected List<String> _getPageTitlesWithRule(FormQuestion question)
255    {
256        return question.getPageRules()
257            .stream()
258            .filter(r -> r.getType() != PageRuleType.FINISH)
259            .map(FormPageRule::getPageId)
260            .distinct()
261            .map(this::_getFormPage)
262            .map(FormPage::getTitle)
263            .toList();
264    }
265    
266    private FormPage _getFormPage(String pageId)
267    {
268        return _resolver.resolveById(pageId);
269    }
270    
271    /**
272     * Get view for question type
273     * @param typeID id of the question type
274     * @param formId id of the form
275     * @return the view parsed in json for configurableFormPanel 
276     * @throws ProcessingException  error while parsing view to json
277     */
278    @Callable
279    public Map<String, Object> getQuestionParametersDefinitions(String typeID, String formId) throws ProcessingException
280    {
281        Map<String, Object> response = new HashMap<>();
282        Form form = _resolver.resolveById(formId);
283        FormQuestionType questionType = _formQuestionTypeExtensionPoint.getExtension(typeID);
284        View view = questionType.getView();
285        response.put("parameters", view.toJSON(DefinitionContext.newInstance().withEdition(true)));
286        response.put("questionNames", form.getQuestionsNames());
287        return response;
288    }
289    
290    /**
291     * Get questions parameters values
292     * @param questionID id of current question
293     * @return map of question parameters value 
294     */
295    @Callable
296    public Map<String, Object> getQuestionParametersValues(String questionID)
297    {
298        Map<String, Object> results = new HashMap<>();
299        
300        FormQuestion question = _resolver.resolveById(questionID);
301        FormQuestionType type = question.getType();
302        Collection< ? extends ModelItem> questionModelItems = type.getModel().getModelItems();
303        Map<String, Object> parametersValues = _parametersManager.getParametersValues(questionModelItems, question, StringUtils.EMPTY);
304        results.put("values", parametersValues);
305        
306        // Repeater values aren't handled by getParametersValues()
307        @SuppressWarnings("unchecked")
308        List<Map<String, Object>> repeaters = _parametersManager.getRepeatersValues((Collection<ModelItem>) questionModelItems, question, StringUtils.EMPTY);
309        results.put("repeaters", repeaters);
310        results.put("fieldToDisable", _getFieldNameToDisable(question));
311        
312        return results;
313    }
314    
315    private List<String> _getFieldNameToDisable(FormQuestion question)
316    {
317        Form form = question.getForm();
318        if (form.getEntries().isEmpty())
319        {
320            return List.of();
321        }
322        
323        return question.getType().getFieldToDisableIfFormPublished(question);
324    }
325    
326    /**
327     * Creates a {@link FormQuestion}.
328     * @param pageId id of current page
329     * @param typeId id of FormQuestionType
330     * @return The id of the created form question, the id of the page and the id of the form
331     */
332    @Callable
333    public Map<String, Object> createQuestion(String pageId, String typeId)
334    {
335        Map<String, Object> result = new HashMap<>();
336        FormPage page = _resolver.resolveById(pageId);
337
338        _checkRights(page);
339        
340        FormQuestionType type = _formQuestionTypeExtensionPoint.getExtension(typeId);
341        Form form = page.getForm();
342        
343        String defaultTitle = _i18nUtils.translate(type.getDefaultTitle());
344        String nameForForm = form.findUniqueQuestionName(NameHelper.filterName(defaultTitle));
345        
346        String id = nameForForm;
347        int i = 1;
348        while (page.hasChild(id))
349        {
350            id = nameForForm + "-" + i;
351            i++;
352        }
353        FormQuestion question = page.createChild(id, "ametys:form-question");
354        question.setNameForForm(nameForForm);
355        question.setTypeId(typeId);
356        
357        Model model = question.getType().getModel();
358        for (ModelItem modelItem : model.getModelItems())
359        {
360            if (modelItem instanceof ElementDefinition)
361            {
362                Object defaultValue = ((ElementDefinition) modelItem).getDefaultValue();
363                if (defaultValue != null)
364                {
365                    question.setValue(modelItem.getPath(), defaultValue);
366                }
367            }
368        }
369        
370        question.setTitle(form.findUniqueQuestionTitle(defaultTitle));
371        
372        page.saveChanges();
373        
374        Map<String, Object> eventParams = new HashMap<>();
375        eventParams.put("form", page.getForm());
376        _observationManager.notify(new Event(FormEvents.FORM_MODIFIED, _getCurrentUser(), eventParams));
377        
378        result.put("id", question.getId());
379        result.put("pageId", page.getId());
380        result.put("formId", question.getForm().getId());
381        result.put("type", typeId);
382        return result;
383    }
384    
385    /**
386     * Rename a {@link FormQuestion}
387     * @param id The id of the question 
388     * @param newName The new name of the question
389     * @return A result map
390     */
391    @Callable
392    public Map<String, String> renameQuestion (String id, String newName)
393    {
394        Map<String, String> results = new HashMap<>();
395        
396        FormQuestion question = _resolver.resolveById(id);
397        _checkRights(question);
398        
399        question.setTitle(newName);
400        question.saveChanges();
401        
402        Map<String, Object> eventParams = new HashMap<>();
403        eventParams.put("form", question.getForm());
404        _observationManager.notify(new Event(FormEvents.FORM_MODIFIED, _getCurrentUser(), eventParams));
405        
406        results.put("id", id);
407        results.put("newName", newName);
408        results.put("formId", question.getForm().getId());
409        
410        return results;
411    }
412    
413    /**
414     * Edits a {@link FormQuestion}.
415     * @param questionId id of current question
416     * @param values The question's values
417     * @return The id of the edited form question, the id of the page and the id of the form
418     */
419    @Callable
420    public Map<String, Object> editQuestion (String questionId, Map<String, Object> values)
421    {
422        Map<String, Object> result = new HashMap<>();
423        Map<String, I18nizableText> errors = new HashMap<>();
424        
425        FormQuestion question = _resolver.resolveById(questionId);
426        _checkRights(question);
427        
428        Form parentForm = question.getForm();
429        String questionName = StringUtils.defaultString((String) values.get("name-for-form"));
430        
431        // if question can not be answered by user, id can't be changed and is unique by default
432        if (!question.getType().canBeAnsweredByUser(question) || questionName.equals(question.getNameForForm()) || parentForm.isQuestionNameUnique(questionName))
433        {
434            FormQuestionType type = question.getType();
435            type.validateQuestionValues(values, errors);
436
437            if (!errors.isEmpty())
438            {
439                result.put("errors", errors);
440                return result;
441            }
442
443            _parametersManager.setParameterValues(question.getDataHolder(), type.getModel().getModelItems(), values);
444            type.doAdditionalOperations(question, values);
445            
446            question.saveChanges();
447            
448            Map<String, Object> eventParams = new HashMap<>();
449            eventParams.put("form", parentForm);
450            _observationManager.notify(new Event(FormEvents.FORM_MODIFIED, _getCurrentUser(), eventParams));
451            
452            result.put("id", question.getId());
453            result.put("pageId", question.getParent().getId());
454            result.put("formId", parentForm.getId());
455            result.put("type", question.getType().toString());
456        }
457        else
458        {
459            errors.put("duplicate_name", new I18nizableText("plugin.forms", "PLUGINS_FORMS_QUESTIONS_SET_ID_ERROR"));
460            result.put("errors", errors);
461            getLogger().error("An error occurred creating the question. The identifier value '" + questionName + "' is already used.");
462        }
463        
464        return result;
465    }
466    
467    /**
468     * Deletes a {@link FormQuestion}.
469     * @param id The id of the form question to delete
470     * @return The id of the form question, the id of the page and the id of the form
471     */
472    @Callable
473    public Map<String, String> deleteQuestion (String id)
474    {
475        FormQuestion question = _resolver.resolveById(id);
476        _checkRights(question);
477        
478        question.getForm().deleteQuestionsRule(question.getId());
479        
480        FormPage page = question.getParent();
481        question.remove();
482        
483        page.saveChanges();
484        
485        Map<String, Object> eventParams = new HashMap<>();
486        eventParams.put("form", page.getForm());
487        _observationManager.notify(new Event(FormEvents.FORM_MODIFIED, _getCurrentUser(), eventParams));
488        
489        return Map.of("id", id);
490    }
491
492    /**
493     * Copies and pastes a form question.
494     * @param pageId The id of the page, target of the copy
495     * @param questionId The id of the question to copy
496     * @return The id of the created question, the id of the page and the id of the form
497     */
498    @Callable
499    public Map<String, String> copyQuestion(String pageId, String questionId)
500    {
501        Map<String, String> result = new HashMap<>();
502        
503        FormQuestion originalQuestion = _resolver.resolveById(questionId);
504        _checkRights(originalQuestion);
505        
506        FormPage parentPage = _resolver.resolveById(pageId);
507        
508        Form parentForm = parentPage.getForm();
509        
510        String questionName = parentForm.findUniqueQuestionName(originalQuestion.getNameForForm());
511        FormQuestion questionCopy = parentPage.createChild(questionName, "ametys:form-question");
512        originalQuestion.copyTo(questionCopy);
513        
514        String copyTitle = _i18nUtils.translate(new I18nizableText("plugin.forms", "PLUGIN_FORMS_TREE_COPY_NAME_PREFIX")) + originalQuestion.getTitle();
515        questionCopy.setTitle(parentForm.findUniqueQuestionTitle(copyTitle));
516        questionCopy.setTypeId(originalQuestion.getType().getId());
517        questionCopy.setNameForForm(questionName);
518
519        parentPage.saveChanges();
520        
521        Map<String, Object> eventParams = new HashMap<>();
522        eventParams.put("form", parentForm);
523        _observationManager.notify(new Event(FormEvents.FORM_MODIFIED, _getCurrentUser(), eventParams));
524        
525        result.put("id", questionCopy.getId());
526        result.put("pageId", parentPage.getId());
527        result.put("formId", parentForm.getId());
528        result.put("type", questionCopy.getType().getId());
529        
530        return result;
531    }
532    
533    /**
534     * Gets the page rules for a form question.
535     * @param id The id of the form question.
536     * @param number The question number
537     * @return The rules
538     * @throws Exception error while getting choice options
539     */
540    @Callable
541    public Map<String, Object> getRules (String id, int number) throws Exception
542    {
543        Map<String, Object> result = new HashMap<>();
544        
545        FormQuestion question = _resolver.resolveById(id);
546        FormQuestionType type = question.getType();
547        if (type instanceof ChoicesListQuestionType cLType)
548        {
549            ChoiceSourceType sourceType = cLType.getSourceType(question);
550          
551            result.put("id", question.getId());
552            result.put("number", String.valueOf(number));
553            result.put("title", question.getTitle());
554            
555            List<Object> rules = new ArrayList<>();
556            for (FormPageRule rule : question.getPageRules())
557            {
558                String option = rule.getOption();
559                Map<String, Object> enumParam = new HashMap<>();
560                enumParam.put(AbstractSourceType.QUESTION_PARAM_KEY, question);
561                I18nizableText label = sourceType.getEntry(new ChoiceOption(option), enumParam);
562                
563                Map<String, Object> resultRule = new HashMap<>();
564                resultRule.put("option", option);
565                resultRule.put("optionLabel", label);
566                resultRule.put("type", rule.getType());
567                String pageId = rule.getPageId();
568                if (pageId != null)
569                {
570                    try
571                    {
572                        FormPage page = _resolver.resolveById(pageId);
573                        resultRule.put("page", pageId);
574                        resultRule.put("pageName", page.getTitle());
575                    }
576                    catch (UnknownAmetysObjectException e)
577                    {
578                        // Page does not exist anymore
579                    }
580                }
581                        
582                rules.add(resultRule);
583            }
584            
585            result.put("rules", rules);
586        }
587        
588        return result;
589    }
590
591    /**
592     * Adds a new rule to a question.
593     * @param id The question id
594     * @param option The option
595     * @param rule The rule type
596     * @param page The page to jump or skip
597     * @return An empty map, or an error
598     */
599    @Callable
600    public Map<String, Object> addPageRule (String id, String option, String rule, String page)
601    {
602        Map<String, Object> result = new HashMap<>();
603        
604        FormQuestion question = _resolver.resolveById(id);
605        _checkRights(question);
606        
607        // Check if exists
608        if (question.hasPageRule(option))
609        {
610            result.put("error", "already-exists");
611            return result;
612        }
613        
614        question.addPageRules(option, PageRuleType.valueOf(rule), page);
615        question.saveChanges();
616        
617        Map<String, Object> eventParams = new HashMap<>();
618        eventParams.put("form", question.getForm());
619        _observationManager.notify(new Event(FormEvents.FORM_MODIFIED, _getCurrentUser(), eventParams));
620        
621        result.put("id", question.getId());
622        result.put("pageId", question.getFormPage().getId());
623        result.put("formId", question.getForm().getId());
624        result.put("type", question.getType().getId());
625        return result;
626    }
627    
628    /**
629     * Deletes a rule to a question.
630     * @param id The question id
631     * @param option The option to delete
632     * @return An empty map
633     */
634    @Callable
635    public Map<String, Object> deletePageRule (String id, String option)
636    {
637        FormQuestion question = _resolver.resolveById(id);
638        _checkRights(question);
639        
640        question.deletePageRule(option);
641        question.saveChanges();
642        
643        Map<String, Object> eventParams = new HashMap<>();
644        eventParams.put("form", question.getForm());
645        _observationManager.notify(new Event(FormEvents.FORM_MODIFIED, _getCurrentUser(), eventParams));
646        
647        return new HashMap<>();
648    }
649}