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.dao;
017
018import java.util.ArrayList;
019import java.util.HashMap;
020import java.util.LinkedHashMap;
021import java.util.List;
022import java.util.Map;
023import java.util.Optional;
024import java.util.Set;
025import java.util.stream.Collectors;
026
027import javax.jcr.RepositoryException;
028
029import org.apache.avalon.framework.component.Component;
030import org.apache.avalon.framework.service.ServiceException;
031import org.apache.avalon.framework.service.ServiceManager;
032import org.apache.avalon.framework.service.Serviceable;
033import org.apache.cocoon.ProcessingException;
034import org.apache.commons.lang3.StringUtils;
035
036import org.ametys.cms.search.ui.model.SearchUIColumn;
037import org.ametys.cms.search.ui.model.SearchUIColumnHelper;
038import org.ametys.core.observation.Event;
039import org.ametys.core.observation.ObservationManager;
040import org.ametys.core.right.RightManager;
041import org.ametys.core.right.RightManager.RightResult;
042import org.ametys.core.ui.Callable;
043import org.ametys.core.user.CurrentUserProvider;
044import org.ametys.core.user.UserIdentity;
045import org.ametys.plugins.forms.FormEvents;
046import org.ametys.plugins.forms.actions.GetFormEntriesAction;
047import org.ametys.plugins.forms.helper.FormElementDefinitionHelper;
048import org.ametys.plugins.forms.helper.LimitedEntriesHelper;
049import org.ametys.plugins.forms.question.FormQuestionType;
050import org.ametys.plugins.forms.question.types.ChoicesListQuestionType;
051import org.ametys.plugins.forms.question.types.FileQuestionType;
052import org.ametys.plugins.forms.question.types.MatrixQuestionType;
053import org.ametys.plugins.forms.question.types.MultipleAwareFormQuestionType;
054import org.ametys.plugins.forms.question.types.RestrictiveQuestionType;
055import org.ametys.plugins.forms.question.types.RichTextQuestionType;
056import org.ametys.plugins.forms.repository.Form;
057import org.ametys.plugins.forms.repository.FormEntry;
058import org.ametys.plugins.forms.repository.FormQuestion;
059import org.ametys.plugins.repository.AmetysObject;
060import org.ametys.plugins.repository.AmetysObjectIterable;
061import org.ametys.plugins.repository.AmetysObjectResolver;
062import org.ametys.plugins.repository.AmetysRepositoryException;
063import org.ametys.plugins.repository.ModifiableTraversableAmetysObject;
064import org.ametys.plugins.repository.UnknownAmetysObjectException;
065import org.ametys.plugins.repository.jcr.JCRAmetysObject;
066import org.ametys.plugins.workflow.support.WorkflowProvider;
067import org.ametys.plugins.workflow.support.WorkflowProvider.AmetysObjectWorkflow;
068import org.ametys.runtime.i18n.I18nizableText;
069import org.ametys.runtime.model.DefinitionContext;
070import org.ametys.runtime.model.ElementDefinition;
071import org.ametys.runtime.model.Model;
072import org.ametys.runtime.model.ModelItem;
073import org.ametys.runtime.model.type.ModelItemTypeConstants;
074import org.ametys.runtime.plugin.component.AbstractLogEnabled;
075import org.ametys.web.parameters.ParametersManager;
076
077import com.opensymphony.workflow.spi.Step;
078
079/**
080 * Form entry DAO.
081 */
082public class FormEntryDAO extends AbstractLogEnabled implements Serviceable, Component
083{
084    /** The Avalon role name. */
085    public static final String ROLE = FormEntryDAO.class.getName();
086
087    /** Name for entries root jcr node */
088    public static final String ENTRIES_ROOT = "ametys-internal:form-entries";
089    
090    /** The right id to consult form entries */
091    public static final String HANDLE_FORMS_ENTRIES_RIGHT_ID = "Form_Entries_Rights_Data";
092    
093    /** The right id to delete form entries */
094    public static final String DELETE_FORMS_ENTRIES_RIGHT_ID = "Runtime_Rights_Forms_Entry_Delete";
095    
096    /** Ametys object resolver. */
097    protected AmetysObjectResolver _resolver;
098    /** The parameters manager */
099    protected ParametersManager _parametersManager;
100    /** Observer manager. */
101    protected ObservationManager _observationManager;
102    /** The current user provider. */
103    protected CurrentUserProvider _currentUserProvider;
104    /** The handling limited entries helper */
105    protected LimitedEntriesHelper _handleLimitedEntriesHelper;
106    /** The rights manager */
107    protected RightManager _rightManager;
108    /** The current user provider */
109    protected WorkflowProvider _workflowProvider;
110    
111    @Override
112    public void service(ServiceManager serviceManager) throws ServiceException
113    {
114        _resolver = (AmetysObjectResolver) serviceManager.lookup(AmetysObjectResolver.ROLE);
115        _parametersManager = (ParametersManager) serviceManager.lookup(ParametersManager.ROLE);
116        _observationManager = (ObservationManager) serviceManager.lookup(ObservationManager.ROLE);
117        _currentUserProvider = (CurrentUserProvider) serviceManager.lookup(CurrentUserProvider.ROLE);
118        _handleLimitedEntriesHelper = (LimitedEntriesHelper) serviceManager.lookup(LimitedEntriesHelper.ROLE);
119        _rightManager = (RightManager) serviceManager.lookup(RightManager.ROLE);
120        _workflowProvider = (WorkflowProvider) serviceManager.lookup(WorkflowProvider.ROLE);
121    }
122    
123    /**
124     * Check if a user have handle data right on a form element as ametys object
125     * @param userIdentity the user
126     * @param formElement the form element
127     * @return true if the user handle data right for a form element
128     */
129    public boolean hasHandleDataRightOnForm(UserIdentity userIdentity, AmetysObject formElement)
130    {
131        return _rightManager.hasRight(userIdentity, HANDLE_FORMS_ENTRIES_RIGHT_ID, formElement) == RightResult.RIGHT_ALLOW;
132    }
133    
134    /**
135     * Check handle data right for a form element as ametys object
136     * @param formElement the form element as ametys object
137     */
138    public void checkHandleDataRight(AmetysObject formElement)
139    {
140        UserIdentity user = _currentUserProvider.getUser();
141        if (!hasHandleDataRightOnForm(user, formElement))
142        {
143            throw new IllegalAccessError("User '" + user + "' tried to handle form data without convenient right [" + HANDLE_FORMS_ENTRIES_RIGHT_ID + "]");
144        }
145    }
146    
147    /**
148     * Gets properties of a form entry
149     * @param id The id of the form entry
150     * @return The properties
151     */
152    @Callable
153    public Map<String, Object> getFormEntryProperties (String id)
154    {
155        try
156        {
157            FormEntry entry = _resolver.resolveById(id);
158            return getFormEntryProperties(entry);
159        }
160        catch (UnknownAmetysObjectException e)
161        {
162            getLogger().warn("Can't find entry with id: {}. It probably has just been deleted", id, e);
163            Map<String, Object> infos = new HashMap<>();
164            infos.put("id", id);
165            return infos;
166        }
167    }
168    
169    /**
170     * Gets properties of a form entry
171     * @param entry The form entry
172     * @return The properties
173     */
174    public Map<String, Object> getFormEntryProperties (FormEntry entry)
175    {
176        Map<String, Object> properties = new HashMap<>();
177        
178        properties.put("id", entry.getId());
179        properties.put("formId", entry.getForm().getId());
180        properties.put("rights", _getUserRights(entry));
181        
182        return properties;
183    }
184    
185    /**
186     * Get user rights for the given form entry
187     * @param entry the form entry
188     * @return the set of rights
189     */
190    protected Set<String> _getUserRights (FormEntry entry)
191    {
192        UserIdentity user = _currentUserProvider.getUser();
193        return _rightManager.getUserRights(user, entry);
194    }
195    
196    /**
197     * Creates a {@link FormEntry}.
198     * @param form The parent form
199     * @return return the form entry
200     */
201    public FormEntry createEntry(Form form)
202    {
203        ModifiableTraversableAmetysObject entriesRoot;
204        if (form.hasChild(ENTRIES_ROOT))
205        {
206            entriesRoot = form.getChild(ENTRIES_ROOT);
207        }
208        else
209        {
210            entriesRoot = form.createChild(ENTRIES_ROOT, "ametys:collection");
211        }
212        // Find unique name
213        String originalName = "entry";
214        String uniqueName = originalName + "-1";
215        int index = 2;
216        while (entriesRoot.hasChild(uniqueName))
217        {
218            uniqueName = originalName + "-" + (index++);
219        }
220        FormEntry entry =  (FormEntry) entriesRoot.createChild(uniqueName, "ametys:form-entry");
221        
222        Map<String, Object> eventParams = new HashMap<>();
223        eventParams.put("form", form);
224        _observationManager.notify(new Event(FormEvents.FORM_MODIFIED, _currentUserProvider.getUser(), eventParams));
225        
226        return entry;
227    }
228    
229    /**
230     * Get the search model configuration to search form entries
231     * @param formId the identifier of form
232     * @return The search model configuration
233     * @throws ProcessingException If an error occurred
234     */
235    @Callable
236    public Map<String, Object> getSearchModelConfiguration (String formId) throws ProcessingException
237    {
238        Map<String, Object> result = new HashMap<>();
239        
240        Form form = _resolver.resolveById(formId);
241        result.put("criteria", _getCriteria(form));
242        result.put("columns", _getColumns(form));
243
244        result.put("searchUrlPlugin", "forms");
245        result.put("searchUrl", "form/entries.json");
246        result.put("pageSize", 50);
247        return result;
248    }
249    
250    /**
251     * Get criteria to search form entries
252     * @param form the form
253     * @return the criteria as JSON
254     */
255    protected Map<String, Object> _getCriteria(Form form)
256    {
257        // Currently, return no criteria for search entries tool
258        return Map.of();
259    }
260    
261    /**
262     * Get the columns for search form entries
263     * @param form the form
264     * @return the columns as JSON
265     * @throws ProcessingException if an error occurred
266     */
267    protected List<Map<String, Object>> _getColumns(Form form) throws ProcessingException
268    {
269        List<SearchUIColumn> columns = new ArrayList<>();
270        
271        Model formEntryModel = getFormEntryModel(form);
272        
273        ModelItem idAttribute = formEntryModel.getModelItem(FormEntry.ATTRIBUTE_ID);
274        SearchUIColumn idColumn = SearchUIColumnHelper.createModelItemColumn(idAttribute);
275        idColumn.setWidth(80);
276        columns.add(idColumn);
277        
278        ModelItem userAttribute = formEntryModel.getModelItem(FormEntry.ATTRIBUTE_USER);
279        SearchUIColumn userColumn = SearchUIColumnHelper.createModelItemColumn(userAttribute);
280        userColumn.setWidth(150);
281        columns.add(userColumn);
282        
283        ModelItem submitDateAttribute = formEntryModel.getModelItem(FormEntry.ATTRIBUTE_SUBMIT_DATE);
284        SearchUIColumn submitDateColumn = SearchUIColumnHelper.createModelItemColumn(submitDateAttribute);
285        submitDateColumn.setWidth(150);
286        columns.add(submitDateColumn);
287        
288        for (ModelItem modelItem : formEntryModel.getModelItems())
289        {
290            String modelItemName = modelItem.getName();
291            if (!modelItemName.equals(FormEntry.ATTRIBUTE_IP) 
292                 && !modelItemName.equals(FormEntry.ATTRIBUTE_ACTIVE) 
293                 && !modelItemName.equals(FormEntry.ATTRIBUTE_SUBMIT_DATE) 
294                 && !modelItemName.equals(FormEntry.ATTRIBUTE_ID) 
295                 && !modelItemName.equals(FormEntry.ATTRIBUTE_USER)
296                 && !modelItemName.startsWith(ChoicesListQuestionType.OTHER_PREFIX_DATA_NAME))
297            {
298                SearchUIColumn<? extends ModelItem> column = SearchUIColumnHelper.createModelItemColumn(modelItem);
299                
300                FormQuestion question = form.getQuestion(modelItemName);
301                if (question != null)
302                {
303                    FormQuestionType type = question.getType();
304                    
305                    column.setRenderer(Optional.ofNullable(type.getJSRenderer(question))
306                                               .filter(StringUtils::isNotBlank));
307                    
308                    column.setConverter(Optional.ofNullable(type.getJSConverter(question))
309                                                .filter(StringUtils::isNotBlank));
310                    
311                    column.setSortable(_isSortable(question));
312                }
313                
314                columns.add(column);
315            }
316        }
317        
318        List<Map<String, Object>>  columnsInfo = new ArrayList<>();
319        DefinitionContext definitionContext = DefinitionContext.newInstance();
320        for (SearchUIColumn column : columns)
321        {
322            columnsInfo.add(column.toJSON(definitionContext));
323        }
324        
325        if (form.isQueueEnabled())
326        {
327            columnsInfo.add(
328                Map.of("name", FormEntry.SYSTEM_ATTRIBUTE_PREFIX + GetFormEntriesAction.QUEUE_STATUS,
329                        "label", new I18nizableText("plugin.forms", "PLUGINS_FORMS_QUEUE_STATUS_COLUMN_TITLE_LABEL"),
330                        "type", ModelItemTypeConstants.BOOLEAN_TYPE_ID,
331                        "path", FormEntry.SYSTEM_ATTRIBUTE_PREFIX + GetFormEntriesAction.QUEUE_STATUS
332                 )
333            );
334        }
335        
336        if (form.hasWorkflow())
337        {
338            columnsInfo.add(
339                Map.of("name", FormEntry.SYSTEM_ATTRIBUTE_PREFIX + GetFormEntriesAction.FORM_ENTRY_STATUS_ID,
340                       "label", new I18nizableText("plugin.forms", "PLUGINS_FORMS_WORKFLOW_TAB_STATUS_COLUMN_TITLE_LABEL"),
341                       "type", ModelItemTypeConstants.STRING_TYPE_ID,
342                       "path", FormEntry.SYSTEM_ATTRIBUTE_PREFIX + GetFormEntriesAction.FORM_ENTRY_STATUS_ID
343                )
344            );
345        }
346        
347        columnsInfo.add(
348            Map.of("name", FormEntry.SYSTEM_ATTRIBUTE_PREFIX + GetFormEntriesAction.FORM_ENTRY_ACTIVE,
349                    "label", new I18nizableText("plugin.forms", "PLUGINS_FORMS_ENTRY_ACTIVE_COLUMN_TITLE_LABEL"),
350                    "type", ModelItemTypeConstants.BOOLEAN_TYPE_ID,
351                    "path", FormEntry.SYSTEM_ATTRIBUTE_PREFIX + GetFormEntriesAction.FORM_ENTRY_ACTIVE,
352                    "hidden", true
353             )
354        );
355        
356        return columnsInfo;
357    }
358    
359    /**
360     * <code>true</code> if the column link to the question is sortable
361     * @param question the question
362     * @return <code>true</code> if the column link to the question is sortable
363     */
364    protected boolean _isSortable(FormQuestion question)
365    {
366        FormQuestionType type = question.getType();
367        if (type instanceof MultipleAwareFormQuestionType multipleType && multipleType.isMultiple(question))
368        {
369            return false;
370        }
371        
372        if (type instanceof MatrixQuestionType || type instanceof RichTextQuestionType || type instanceof FileQuestionType)
373        {
374            return false;
375        }
376        
377        return true;
378    }
379
380    /**
381     * Deletes a {@link FormEntry}.
382     * @param id The id of the form entry to delete
383     * @return The entry data
384     */
385    @Callable
386    public Map<String, String> deleteEntry (String id)
387    {
388        Map<String, String> result = new HashMap<>();
389        
390        FormEntry entry = _resolver.resolveById(id);
391        
392        if (_rightManager.currentUserHasRight(DELETE_FORMS_ENTRIES_RIGHT_ID, entry) != RightResult.RIGHT_ALLOW)
393        {
394            throw new IllegalAccessError("User '" + _currentUserProvider.getUser() + "' tried to delete entries without convenient right [" + DELETE_FORMS_ENTRIES_RIGHT_ID + "]");
395        }
396        
397        _handleLimitedEntriesHelper.deactivateEntry(id);
398        
399        Form form = entry.getForm();
400        entry.remove();
401        
402        form.saveChanges();
403        
404        Map<String, Object> eventParams = new HashMap<>();
405        eventParams.put("form", form);
406        _observationManager.notify(new Event(FormEvents.FORM_MODIFIED, _currentUserProvider.getUser(), eventParams));
407        
408        result.put("entryId", id);
409        result.put("formId", form.getId());
410        result.put("hasEntries", String.valueOf(form.hasEntries()));
411        
412        return result;
413    }
414    
415    /**
416     * Delete all entries of a form
417     * @param id The id of the form
418     * @return the deleted entries data
419     */
420    @Callable
421    public Map<String, Object> clearEntries(String id)
422    {
423        Map<String, Object> result = new HashMap<>();
424        List<String> entryIds = new ArrayList<>();
425        Form form = _resolver.resolveById(id);
426        if (_rightManager.currentUserHasRight(DELETE_FORMS_ENTRIES_RIGHT_ID, form) != RightResult.RIGHT_ALLOW)
427        {
428            throw new IllegalAccessError("User '" + _currentUserProvider.getUser() + "' tried to delete entries without convenient right [" + DELETE_FORMS_ENTRIES_RIGHT_ID + "]");
429        }
430        
431        for (FormEntry entry: form.getEntries())
432        {
433            entryIds.add(entry.getId());
434            entry.remove();
435        }
436        form.saveChanges();
437        Map<String, Object> eventParams = new HashMap<>();
438        eventParams.put("form", form);
439        _observationManager.notify(new Event(FormEvents.FORM_MODIFIED, _currentUserProvider.getUser(), eventParams));
440        
441        result.put("ids", entryIds);
442        result.put("formId", form.getId());
443        
444        return result;
445    }
446
447    /**
448     * Retrieves the current step id of the form entry
449     * @param entry The form entry
450     * @return the current step id
451     * @throws AmetysRepositoryException if an error occurs.
452     */
453    public Long getCurrentStepId(FormEntry entry) throws AmetysRepositoryException
454    {
455        AmetysObjectWorkflow workflow = _workflowProvider.getAmetysObjectWorkflow(entry);
456        try 
457        {
458            Step currentStep = (Step) workflow.getCurrentSteps(entry.getWorkflowId()).iterator().next();
459            return Long.valueOf(currentStep.getStepId());
460        }
461        catch (AmetysRepositoryException e)
462        {
463            return RestrictiveQuestionType.INITIAL_WORKFLOW_ID; // can occur when entry has just been created and workflow is not yet initialized
464        }
465    }
466    
467    /**
468     * Get the form entry model
469     * @param form the form
470     * @return the form entry model
471     */
472    public Model getFormEntryModel(Form form)
473    {
474        Map<String, ModelItem> items = new LinkedHashMap<>();
475        for (FormQuestion question : form.getQuestions())
476        {
477            FormQuestionType type = question.getType();
478            if (!type.onlyForDisplay(question))
479            {
480                Model entryModel = question.getType().getEntryModel(question);
481                for (ModelItem modelItem : entryModel.getModelItems())
482                {
483                    items.put(modelItem.getName(), modelItem);
484                }
485                
486                if (type instanceof ChoicesListQuestionType cLType)
487                {
488                    ModelItem otherFieldModel = cLType.getOtherFieldModel(question);
489                    if (otherFieldModel != null)
490                    {
491                        items.put(ChoicesListQuestionType.OTHER_PREFIX_DATA_NAME + question.getNameForForm(), otherFieldModel);
492                    }
493                }
494            }
495        }
496        
497        ElementDefinition idModelItem = FormElementDefinitionHelper.getElementDefinition(FormEntry.ATTRIBUTE_ID, ModelItemTypeConstants.LONG_TYPE_ID, "PLUGIN_FORMS_MODEL_ITEM_ID_LABEL", null, null);
498        items.put(idModelItem.getName(), idModelItem);
499        
500        ElementDefinition userModelItem = FormElementDefinitionHelper.getElementDefinition(FormEntry.ATTRIBUTE_USER, org.ametys.cms.data.type.ModelItemTypeConstants.USER_ELEMENT_TYPE_ID, "PLUGIN_FORMS_MODEL_ITEM_USER_LABEL", null, null);
501        items.put(userModelItem.getName(), userModelItem);
502        
503        ElementDefinition activeModelItem = FormElementDefinitionHelper.getElementDefinition(FormEntry.ATTRIBUTE_ACTIVE, ModelItemTypeConstants.BOOLEAN_TYPE_ID, "PLUGIN_FORMS_MODEL_ITEM_ACTIVE_LABEL", null, null);
504        items.put(activeModelItem.getName(), activeModelItem);
505        
506        ElementDefinition submitDateModelItem = FormElementDefinitionHelper.getElementDefinition(FormEntry.ATTRIBUTE_SUBMIT_DATE, ModelItemTypeConstants.DATETIME_TYPE_ID, "PLUGIN_FORMS_MODEL_ITEM_SUBMISSION_DATE_LABEL", null, null);
507        items.put(submitDateModelItem.getName(), submitDateModelItem);
508        
509        ElementDefinition ipAddressModelItem = FormElementDefinitionHelper.getElementDefinition(FormEntry.ATTRIBUTE_IP, ModelItemTypeConstants.STRING_TYPE_ID, "PLUGIN_FORMS_MODEL_ITEM_IP_LABEL", null, null);
510        items.put(ipAddressModelItem.getName(), ipAddressModelItem);
511        
512        return Model.of(
513            "form.entry.model.id", 
514            "form.entry.model.family.id", 
515            items.values().toArray(new ModelItem[items.size()])
516        );
517    }
518    
519    /**
520     * Get the form entries
521     * @param form the form
522     * @param onlyActiveEntries <code>true</code> to have only active entries
523     * @param sorts the list of sort
524     * @return the form entries
525     */
526    public List<FormEntry> getFormEntries(Form form, boolean onlyActiveEntries, List<Sort> sorts)
527    {
528        try
529        {
530            String uuid = ((JCRAmetysObject) form).getNode().getIdentifier();
531            String xpathQuery = "//element(*, ametys:form)[@jcr:uuid = '" + uuid + "']//element(*, ametys:form-entry)";
532            if (onlyActiveEntries)
533            {
534                xpathQuery += "[@ametys:ametys-active = 'true']";
535            }
536            
537            String sortsAsString = "";
538            for (Sort sort : sorts)
539            {
540                if (StringUtils.isNotBlank(sortsAsString))
541                {
542                    sortsAsString += ", ";
543                }
544                
545                sortsAsString += "@ametys:" + sort.attributeName() + " " + sort.direction();
546            }
547            
548            if (StringUtils.isNotBlank(sortsAsString))
549            {
550                xpathQuery += " order by " + sortsAsString; 
551            }
552            
553            AmetysObjectIterable<FormEntry> formEntries = _resolver.query(xpathQuery);
554            return formEntries.stream()
555                    .collect(Collectors.toList());
556        }
557        catch (RepositoryException e)
558        {
559            getLogger().error("An error occurred getting entries of form '" + form.getId() + "'");
560        }
561        
562        return List.of();
563    }
564    
565    /**
566     * Record representing a sort with form attribute name and direction
567     * @param attributeName the attribute name
568     * @param direction the direction
569     */
570    public record Sort(String attributeName, String direction) { /* */ }
571}