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.time.ZonedDateTime;
019import java.util.ArrayList;
020import java.util.Comparator;
021import java.util.HashMap;
022import java.util.List;
023import java.util.Map;
024import java.util.Objects;
025import java.util.Optional;
026import java.util.Set;
027import java.util.stream.Collectors;
028
029import javax.jcr.RepositoryException;
030
031import org.apache.avalon.framework.component.Component;
032import org.apache.avalon.framework.service.ServiceException;
033import org.apache.avalon.framework.service.ServiceManager;
034import org.apache.avalon.framework.service.Serviceable;
035import org.apache.cocoon.ProcessingException;
036import org.apache.commons.lang3.StringUtils;
037import org.apache.jackrabbit.JcrConstants;
038
039import org.ametys.cms.fo.ForceDefaultRepositoryWorkspaceCallableDecorator;
040import org.ametys.cms.search.SearchResults;
041import org.ametys.cms.search.query.AndQuery;
042import org.ametys.cms.search.query.BooleanQuery;
043import org.ametys.cms.search.query.DateQuery;
044import org.ametys.cms.search.query.DocumentTypeQuery;
045import org.ametys.cms.search.query.NotQuery;
046import org.ametys.cms.search.query.OrQuery;
047import org.ametys.cms.search.query.Query;
048import org.ametys.cms.search.query.Query.Operator;
049import org.ametys.cms.search.query.UsersQuery;
050import org.ametys.cms.search.solr.SearcherFactory;
051import org.ametys.cms.search.solr.SearcherFactory.FacetDefinition;
052import org.ametys.cms.search.solr.SearcherFactory.Searcher;
053import org.ametys.cms.search.solr.SearcherFactory.SortDefinition;
054import org.ametys.cms.search.ui.model.SearchUIColumn;
055import org.ametys.cms.search.ui.model.SearchUIColumnHelper;
056import org.ametys.core.observation.Event;
057import org.ametys.core.observation.ObservationManager;
058import org.ametys.core.right.RightManager;
059import org.ametys.core.right.RightManager.RightResult;
060import org.ametys.core.ui.Callable;
061import org.ametys.core.user.CurrentUserProvider;
062import org.ametys.core.user.User;
063import org.ametys.core.user.UserIdentity;
064import org.ametys.core.user.UserManager;
065import org.ametys.plugins.forms.FormEntrySystemPropertyExtensionPoint;
066import org.ametys.plugins.forms.ObservationConstants;
067import org.ametys.plugins.forms.actions.GetFormEntriesAction;
068import org.ametys.plugins.forms.helper.FormElementDefinitionHelper;
069import org.ametys.plugins.forms.helper.FormEntriesSearchHelper;
070import org.ametys.plugins.forms.helper.FormEntriesSearchHelper.FormEntryColumn;
071import org.ametys.plugins.forms.helper.FormWorkflowHelper;
072import org.ametys.plugins.forms.helper.LimitedEntriesHelper;
073import org.ametys.plugins.forms.indexing.solr.SolrFormEntryIndexer;
074import org.ametys.plugins.forms.indexing.solr.query.FormQuery;
075import org.ametys.plugins.forms.question.FormQuestionType;
076import org.ametys.plugins.forms.question.types.RestrictiveAwareQuestionType;
077import org.ametys.plugins.forms.question.types.impl.ChoicesListQuestionType;
078import org.ametys.plugins.forms.question.types.impl.ComputedQuestionType;
079import org.ametys.plugins.forms.repository.Form;
080import org.ametys.plugins.forms.repository.FormEntry;
081import org.ametys.plugins.forms.repository.FormQuestion;
082import org.ametys.plugins.forms.rights.FormsDirectoryRightAssignmentContext;
083import org.ametys.plugins.repository.AmetysObject;
084import org.ametys.plugins.repository.AmetysObjectIterable;
085import org.ametys.plugins.repository.AmetysObjectResolver;
086import org.ametys.plugins.repository.AmetysRepositoryException;
087import org.ametys.plugins.repository.ModifiableTraversableAmetysObject;
088import org.ametys.plugins.repository.UnknownAmetysObjectException;
089import org.ametys.plugins.repository.query.QueryHelper;
090import org.ametys.plugins.repository.query.expression.AndExpression;
091import org.ametys.plugins.repository.query.expression.BooleanExpression;
092import org.ametys.plugins.repository.query.expression.Expression;
093import org.ametys.plugins.workflow.support.WorkflowProvider;
094import org.ametys.plugins.workflow.support.WorkflowProvider.AmetysObjectWorkflow;
095import org.ametys.runtime.authentication.AccessDeniedException;
096import org.ametys.runtime.authentication.AuthorizationRequiredException;
097import org.ametys.runtime.i18n.I18nizableText;
098import org.ametys.runtime.model.DefinitionContext;
099import org.ametys.runtime.model.Model;
100import org.ametys.runtime.model.ModelItem;
101import org.ametys.runtime.model.View;
102import org.ametys.runtime.model.type.ModelItemTypeConstants;
103import org.ametys.runtime.plugin.component.AbstractLogEnabled;
104import org.ametys.web.parameters.ParametersManager;
105import org.ametys.web.repository.site.Site;
106import org.ametys.web.search.query.SiteQuery;
107
108import com.opensymphony.workflow.spi.Step;
109
110/**
111 * Form entry DAO.
112 */
113public class FormEntryDAO extends AbstractLogEnabled implements Serviceable, Component
114{
115    /** The Avalon role name. */
116    public static final String ROLE = FormEntryDAO.class.getName();
117
118    /** Name for entries root jcr node */
119    public static final String ENTRIES_ROOT = "ametys-internal:form-entries";
120    
121    /** The right id to consult form entries */
122    public static final String HANDLE_FORMS_ENTRIES_RIGHT_ID = "Form_Entries_Rights_Data";
123    
124    /** The right id to delete form entries */
125    public static final String DELETE_FORMS_ENTRIES_RIGHT_ID = "Runtime_Rights_Forms_Entry_Delete";
126    
127    /** Ametys object resolver. */
128    protected AmetysObjectResolver _resolver;
129    /** The parameters manager */
130    protected ParametersManager _parametersManager;
131    /** Observer manager. */
132    protected ObservationManager _observationManager;
133    /** The current user provider. */
134    protected CurrentUserProvider _currentUserProvider;
135    /** The handling limited entries helper */
136    protected LimitedEntriesHelper _handleLimitedEntriesHelper;
137    /** The rights manager */
138    protected RightManager _rightManager;
139    /** The current user provider */
140    protected WorkflowProvider _workflowProvider;
141    /** The user manager */
142    protected UserManager _userManager;
143    /** The system property extension point */
144    protected FormEntrySystemPropertyExtensionPoint _formSystemPropertyEP;
145    /** The form element definition helper */
146    protected FormElementDefinitionHelper _formElementDefinitionHelper;
147    /** The form entry DAO */
148    protected FormEntryDAO _formEntryDAO;
149    /** The searcher factory */
150    protected SearcherFactory _searcherFactory;
151    /** The form workflow helper */
152    protected FormWorkflowHelper _formWorkflowHelper;
153    /** The form entries search helper */
154    protected FormEntriesSearchHelper _formEntriesSearchHelper;
155
156    @Override
157    public void service(ServiceManager serviceManager) throws ServiceException
158    {
159        _resolver = (AmetysObjectResolver) serviceManager.lookup(AmetysObjectResolver.ROLE);
160        _parametersManager = (ParametersManager) serviceManager.lookup(ParametersManager.ROLE);
161        _observationManager = (ObservationManager) serviceManager.lookup(ObservationManager.ROLE);
162        _currentUserProvider = (CurrentUserProvider) serviceManager.lookup(CurrentUserProvider.ROLE);
163        _handleLimitedEntriesHelper = (LimitedEntriesHelper) serviceManager.lookup(LimitedEntriesHelper.ROLE);
164        _rightManager = (RightManager) serviceManager.lookup(RightManager.ROLE);
165        _workflowProvider = (WorkflowProvider) serviceManager.lookup(WorkflowProvider.ROLE);
166        _userManager = (UserManager) serviceManager.lookup(UserManager.ROLE);
167        _formSystemPropertyEP = (FormEntrySystemPropertyExtensionPoint) serviceManager.lookup(FormEntrySystemPropertyExtensionPoint.ROLE);
168        _formElementDefinitionHelper = (FormElementDefinitionHelper) serviceManager.lookup(FormElementDefinitionHelper.ROLE);
169        _formEntryDAO = (FormEntryDAO) serviceManager.lookup(FormEntryDAO.ROLE);
170        _searcherFactory = (SearcherFactory) serviceManager.lookup(SearcherFactory.ROLE);
171        _formWorkflowHelper = (FormWorkflowHelper) serviceManager.lookup(FormWorkflowHelper.ROLE);
172        _formEntriesSearchHelper = (FormEntriesSearchHelper) serviceManager.lookup(FormEntriesSearchHelper.ROLE);
173    }
174    
175    /**
176     * Check if a user have handle data right on a form element as ametys object
177     * @param userIdentity the user
178     * @param formElement the form element
179     * @return true if the user handle data right for a form element
180     */
181    public boolean hasHandleDataRightOnForm(UserIdentity userIdentity, AmetysObject formElement)
182    {
183        return _rightManager.hasRight(userIdentity, HANDLE_FORMS_ENTRIES_RIGHT_ID, formElement) == RightResult.RIGHT_ALLOW;
184    }
185    
186    /**
187     * Check handle data right for a form element as ametys object
188     * @param formElement the form element as ametys object
189     */
190    public void checkHandleDataRight(AmetysObject formElement)
191    {
192        UserIdentity user = _currentUserProvider.getUser();
193        if (user == null)
194        {
195            // User not yet authenticated
196            throw new AuthorizationRequiredException();
197        }
198        
199        if (!hasHandleDataRightOnForm(user, formElement))
200        {
201            throw new AccessDeniedException("User '" + user + "' tried to handle form data without convenient right [" + HANDLE_FORMS_ENTRIES_RIGHT_ID + "]");
202        }
203    }
204    
205    /**
206     * Gets properties of a form entry
207     * @param id The id of the form entry
208     * @return The properties
209     */
210    @Callable (rights = Callable.NO_CHECK_REQUIRED)
211    public Map<String, Object> getFormEntryProperties (String id)
212    {
213        // Assume that no read access is checked (required for bus message target)
214        try
215        {
216            FormEntry entry = _resolver.resolveById(id);
217            return getFormEntryProperties(entry);
218        }
219        catch (UnknownAmetysObjectException e)
220        {
221            getLogger().warn("Can't find entry with id: {}. It probably has just been deleted", id, e);
222            Map<String, Object> infos = new HashMap<>();
223            infos.put("id", id);
224            return infos;
225        }
226    }
227    
228    /**
229     * Gets properties of a form entry
230     * @param entry The form entry
231     * @return The properties
232     */
233    public Map<String, Object> getFormEntryProperties (FormEntry entry)
234    {
235        Map<String, Object> properties = new HashMap<>();
236        
237        properties.put("id", entry.getId());
238        properties.put("formId", entry.getForm().getId());
239        properties.put("rights", _getUserRights(entry));
240        
241        return properties;
242    }
243    
244    /**
245     * Get user rights for the given form entry
246     * @param entry the form entry
247     * @return the set of rights
248     */
249    protected Set<String> _getUserRights (FormEntry entry)
250    {
251        UserIdentity user = _currentUserProvider.getUser();
252        return _rightManager.getUserRights(user, entry);
253    }
254    
255    /**
256     * Creates a {@link FormEntry}.
257     * @param form The parent form
258     * @param clientIP The client IP address
259     * @return return the form entry
260     */
261    public FormEntry createEntry(Form form, String clientIP)
262    {
263        ModifiableTraversableAmetysObject entriesRoot;
264        if (form.hasChild(ENTRIES_ROOT))
265        {
266            entriesRoot = form.getChild(ENTRIES_ROOT);
267        }
268        else
269        {
270            entriesRoot = form.createChild(ENTRIES_ROOT, "ametys:collection");
271        }
272        // Find unique name
273        String originalName = "entry";
274        String uniqueName = originalName + "-1";
275        int index = 2;
276        while (entriesRoot.hasChild(uniqueName))
277        {
278            uniqueName = originalName + "-" + (index++);
279        }
280        FormEntry entry =  (FormEntry) entriesRoot.createChild(uniqueName, "ametys:form-entry");
281        
282        UserIdentity user = _currentUserProvider.getUser();
283        entry.setUser(user);
284        entry.setIP(clientIP);
285        entry.setSubmitDate(ZonedDateTime.now());
286        entry.setActive(true);
287        _setEntryId(entry);
288        
289        entry.saveChanges();
290        
291        Map<String, Object> formParams = new HashMap<>();
292        formParams.put("form", form);
293        _observationManager.notify(new Event(ObservationConstants.FORM_MODIFIED, user, formParams));
294        
295        Map<String, Object> eventParams = new HashMap<>();
296        eventParams.put(ObservationConstants.ARGS_FORM_ENTRY_ID, entry.getId());
297        _observationManager.notify(new Event(ObservationConstants.FORM_ENTRY_ADDED, user, eventParams));
298        
299        return entry;
300    }
301    
302    private void _setEntryId(FormEntry entry)
303    {
304        List<FormEntry> formEntries = getFormEntries(entry.getForm(), false, List.of(new Sort(FormEntry.ATTRIBUTE_ID, "descending")));
305        Long entryId = formEntries.isEmpty() ? 1L : formEntries.get(0).getEntryId() + 1;
306        entry.setEntryId(entryId);
307    }
308    
309    /**
310     * Edit a form entry
311     * @param entry the form entry
312     * @param values the values to set
313     * @param editView the edit view
314     * @param isJustCreated <code>true</code> if the entry is just created before edition
315     * @throws Exception if an error occurs
316     */
317    public void editFormEntry(FormEntry entry, Map<String, Object> values, View editView, boolean isJustCreated) throws Exception
318    {
319        Form form = entry.getForm();
320        
321        entry.synchronizeValues(editView, values);
322        _handleComputedValues(form.getQuestions(), entry, !isJustCreated);
323        
324        if (isJustCreated)
325        {
326            // For creation, workflow needs to be initialized after setting values for send mail function can have access to all entry data 
327            _formWorkflowHelper.initializeWorkflow(entry);
328        }
329        
330        form.saveChanges();
331        
332        Map<String, Object> eventParams = new HashMap<>();
333        eventParams.put(ObservationConstants.ARGS_FORM_ENTRY_ID, entry.getId());
334        _observationManager.notify(new Event(ObservationConstants.FORM_ENTRY_MODIFIED, _currentUserProvider.getUser(), eventParams));
335    }
336    
337    /**
338     * Handle computed values
339     * @param questions the form questions
340     * @param entry the entry
341     * @param forEdition <code>true</code> to handle edition
342     */
343    protected void _handleComputedValues(List<FormQuestion> questions, FormEntry entry, boolean forEdition)
344    {
345        for (FormQuestion question : questions)
346        {
347            FormQuestionType questionType = question.getType();
348            if (questionType instanceof ComputedQuestionType type)
349            {
350                if (!forEdition || type.getComputingType(question).canEdit())
351                {
352                    Object computedValue = type.getComputingType(question).getComputedValue(question, entry);
353                    if (computedValue != null)
354                    {
355                        entry.setValue(question.getNameForForm(), computedValue);
356                    }
357                }
358            }
359        }
360    }
361    
362    /**
363     * Get the search model configuration to search form entries
364     * @param formId the identifier of form
365     * @return The search model configuration
366     * @throws ProcessingException If an error occurred
367     */
368    @Callable (rights = HANDLE_FORMS_ENTRIES_RIGHT_ID, rightContext = FormsDirectoryRightAssignmentContext.ID, paramIndex = 0)
369    public Map<String, Object> getSearchModelConfiguration (String formId) throws ProcessingException
370    {
371        Map<String, Object> result = new HashMap<>();
372        
373        Form form = _resolver.resolveById(formId);
374        result.put("criteria", _getCriteria(form));
375        result.put("columns", _getColumns(form));
376
377        result.put("searchUrlPlugin", "forms");
378        result.put("searchUrl", "form/entries.json");
379        result.put("pageSize", 50);
380        return result;
381    }
382    
383    /**
384     * Get criteria to search form entries
385     * @param form the form
386     * @return the criteria as JSON
387     */
388    protected Map<String, Object> _getCriteria(Form form)
389    {
390        // Currently, return no criteria for search entries tool
391        return Map.of();
392    }
393    
394    /**
395     * Get the columns for search form entries
396     * @param form the form
397     * @return the columns as JSON
398     * @throws ProcessingException if an error occurred
399     */
400    protected List<Map<String, Object>> _getColumns(Form form) throws ProcessingException
401    {
402        List<SearchUIColumn> columns = new ArrayList<>();
403        
404        Model formEntryModel = getFormEntryModel(form);
405        
406        ModelItem idAttribute = formEntryModel.getModelItem(FormEntry.ATTRIBUTE_ID);
407        SearchUIColumn idColumn = SearchUIColumnHelper.createModelItemColumn(idAttribute);
408        idColumn.setWidth(80);
409        columns.add(idColumn);
410        
411        ModelItem userAttribute = formEntryModel.getModelItem(FormEntry.ATTRIBUTE_USER);
412        SearchUIColumn userColumn = SearchUIColumnHelper.createModelItemColumn(userAttribute);
413        userColumn.setWidth(150);
414        columns.add(userColumn);
415        
416        ModelItem submitDateAttribute = formEntryModel.getModelItem(FormEntry.ATTRIBUTE_SUBMIT_DATE);
417        SearchUIColumn submitDateColumn = SearchUIColumnHelper.createModelItemColumn(submitDateAttribute);
418        submitDateColumn.setWidth(150);
419        columns.add(submitDateColumn);
420        
421        for (FormEntryColumn column : _formEntriesSearchHelper.getFormEntryColumns(form).values())
422        {
423            ModelItem modelItem = column.modelItem();
424            String modelItemName = modelItem.getName();
425            // Do not add system attributes to the columns list, they are already added above
426            if (!modelItemName.equals(FormEntry.ATTRIBUTE_ID)
427                 && !modelItemName.equals(FormEntry.ATTRIBUTE_USER)
428                 && !modelItemName.equals(FormEntry.ATTRIBUTE_SUBMIT_DATE))
429            {
430                SearchUIColumn<? extends ModelItem> uiColumn = SearchUIColumnHelper.createModelItemColumn(modelItem);
431                
432                FormQuestion question = form.getQuestion(modelItemName);
433                if (question != null)
434                {
435                    FormQuestionType type = question.getType();
436                    
437                    uiColumn.setRenderer(Optional.ofNullable(type.getJSRenderer(question))
438                                               .filter(StringUtils::isNotBlank));
439                    
440                    uiColumn.setConverter(Optional.ofNullable(type.getJSConverter(question))
441                                                .filter(StringUtils::isNotBlank));
442                    
443                    uiColumn.setSortable(column.sortFieldName() != null);
444                }
445                
446                columns.add(uiColumn);
447            }
448        }
449        
450        List<Map<String, Object>>  columnsInfo = new ArrayList<>();
451        DefinitionContext definitionContext = DefinitionContext.newInstance();
452        for (SearchUIColumn column : columns)
453        {
454            columnsInfo.add(column.toJSON(definitionContext));
455        }
456        
457        if (form.isQueueEnabled())
458        {
459            columnsInfo.add(
460                Map.of("name", FormEntry.SYSTEM_ATTRIBUTE_PREFIX + GetFormEntriesAction.QUEUE_STATUS,
461                        "label", new I18nizableText("plugin.forms", "PLUGINS_FORMS_QUEUE_STATUS_COLUMN_TITLE_LABEL"),
462                        "type", ModelItemTypeConstants.BOOLEAN_TYPE_ID,
463                        "path", FormEntry.SYSTEM_ATTRIBUTE_PREFIX + GetFormEntriesAction.QUEUE_STATUS
464                 )
465            );
466        }
467        
468        columnsInfo.add(
469            Map.of("name", FormEntry.SYSTEM_ATTRIBUTE_PREFIX + GetFormEntriesAction.FORM_ENTRY_ACTIVE,
470                    "label", new I18nizableText("plugin.forms", "PLUGINS_FORMS_ENTRY_ACTIVE_COLUMN_TITLE_LABEL"),
471                    "type", ModelItemTypeConstants.BOOLEAN_TYPE_ID,
472                    "path", FormEntry.SYSTEM_ATTRIBUTE_PREFIX + GetFormEntriesAction.FORM_ENTRY_ACTIVE,
473                    "hidden", true
474             )
475        );
476        
477        return columnsInfo;
478    }
479    
480    /**
481     * Deletes a {@link FormEntry}.
482     * @param id The id of the form entry to delete
483     * @return The entry data
484     */
485    @Callable (rights = DELETE_FORMS_ENTRIES_RIGHT_ID, rightContext = FormsDirectoryRightAssignmentContext.ID, paramIndex = 0)
486    public Map<String, String> deleteEntry (String id)
487    {
488        Map<String, String> result = new HashMap<>();
489        
490        FormEntry entry = _resolver.resolveById(id);
491        
492        _handleLimitedEntriesHelper.deactivateEntry(id);
493        
494        Form form = entry.getForm();
495        entry.remove();
496        
497        _observationManager.notify(new Event(ObservationConstants.FORM_ENTRY_DELETED, _currentUserProvider.getUser(), Map.of(ObservationConstants.ARGS_FORM_ENTRY_IDS, List.of(id))));
498        
499        form.saveChanges();
500        
501        Map<String, Object> eventParams = new HashMap<>();
502        eventParams.put("form", form);
503        _observationManager.notify(new Event(ObservationConstants.FORM_MODIFIED, _currentUserProvider.getUser(), eventParams));
504        
505        result.put("entryId", id);
506        result.put("formId", form.getId());
507        result.put("hasEntries", String.valueOf(form.hasEntries()));
508        
509        return result;
510    }
511    
512    /**
513     * Delete all entries of a form
514     * @param id The id of the form
515     * @return the deleted entries data
516     */
517    @Callable (rights = DELETE_FORMS_ENTRIES_RIGHT_ID, rightContext = FormsDirectoryRightAssignmentContext.ID, paramIndex = 0)
518    public Map<String, Object> clearEntries(String id)
519    {
520        Map<String, Object> result = new HashMap<>();
521        List<String> entryIds = new ArrayList<>();
522        Form form = _resolver.resolveById(id);
523        
524        for (FormEntry entry: form.getEntries())
525        {
526            entryIds.add(entry.getId());
527            entry.remove();
528        }
529        
530        _observationManager.notify(new Event(ObservationConstants.FORM_ENTRY_DELETED, _currentUserProvider.getUser(), Map.of(ObservationConstants.ARGS_FORM_ENTRY_IDS, entryIds)));
531        
532        form.saveChanges();
533        Map<String, Object> eventParams = new HashMap<>();
534        eventParams.put("form", form);
535        _observationManager.notify(new Event(ObservationConstants.FORM_MODIFIED, _currentUserProvider.getUser(), eventParams));
536        
537        result.put("ids", entryIds);
538        result.put("formId", form.getId());
539        
540        return result;
541    }
542
543    /**
544     * Retrieves the current step id of the form entry
545     * @param entry The form entry
546     * @return the current step id
547     * @throws AmetysRepositoryException if an error occurs.
548     */
549    public Long getCurrentStepId(FormEntry entry) throws AmetysRepositoryException
550    {
551        AmetysObjectWorkflow workflow = _workflowProvider.getAmetysObjectWorkflow(entry);
552        try
553        {
554            Step currentStep = (Step) workflow.getCurrentSteps(entry.getWorkflowId()).iterator().next();
555            return Long.valueOf(currentStep.getStepId());
556        }
557        catch (AmetysRepositoryException e)
558        {
559            return RestrictiveAwareQuestionType.INITIAL_WORKFLOW_ID; // can occur when entry has just been created and workflow is not yet initialized
560        }
561    }
562    
563    /**
564     * Get the form entry model
565     * @param form the form
566     * @return the form entry model
567     */
568    public Model getFormEntryModel(Form form)
569    {
570        List<ModelItem> items = new ArrayList<>();
571        for (FormQuestion question : form.getQuestions())
572        {
573            FormQuestionType type = question.getType();
574            if (!type.onlyForDisplay(question))
575            {
576                Model entryModel = question.getType().getEntryModel(question);
577                for (ModelItem modelItem : entryModel.getModelItems())
578                {
579                    items.add(modelItem);
580                }
581                
582                if (type instanceof ChoicesListQuestionType cLType)
583                {
584                    ModelItem otherFieldModel = cLType.getOtherFieldModel(question);
585                    if (otherFieldModel != null)
586                    {
587                        items.add(otherFieldModel);
588                    }
589                }
590            }
591        }
592        
593        items.add(_formElementDefinitionHelper.getElementDefinition(FormEntry.ATTRIBUTE_ID, ModelItemTypeConstants.LONG_TYPE_ID, "PLUGIN_FORMS_MODEL_ITEM_ID_LABEL", null, null));
594        items.add(_formElementDefinitionHelper.getElementDefinition(FormEntry.ATTRIBUTE_USER, ModelItemTypeConstants.USER_ELEMENT_TYPE_ID, "PLUGIN_FORMS_MODEL_ITEM_USER_LABEL", null, null));
595        items.add(_formElementDefinitionHelper.getElementDefinition(FormEntry.ATTRIBUTE_ACTIVE, ModelItemTypeConstants.BOOLEAN_TYPE_ID, "PLUGIN_FORMS_MODEL_ITEM_ACTIVE_LABEL", null, null));
596        items.add(_formElementDefinitionHelper.getElementDefinition(FormEntry.ATTRIBUTE_SUBMIT_DATE, ModelItemTypeConstants.DATETIME_TYPE_ID, "PLUGIN_FORMS_MODEL_ITEM_SUBMISSION_DATE_LABEL", null, null));
597        items.add(_formElementDefinitionHelper.getElementDefinition(FormEntry.ATTRIBUTE_IP, ModelItemTypeConstants.STRING_TYPE_ID, "PLUGIN_FORMS_MODEL_ITEM_IP_LABEL", null, null));
598        items.add(_formElementDefinitionHelper.getElementDefinition(FormEntry.ATTRIBUTE_ANONYMIZATION_DATE, ModelItemTypeConstants.DATETIME_TYPE_ID, "PLUGIN_FORMS_MODEL_ITEM_ANONYMIZATION_DATE_LABEL", null, null));
599        
600        for (String id : _formSystemPropertyEP.getExtensionsIds())
601        {
602            items.add(_formSystemPropertyEP.getExtension(id));
603        }
604        
605        return Model.of(
606            "form.entry.model.id",
607            "form.entry.model.family.id",
608            items.toArray(ModelItem[]::new)
609        );
610    }
611    
612    /**
613     * Get all form entries
614     * @param site the site. Can be null to get all form entries of all sites
615     * @return the list of all form entries
616     */
617    public List<FormEntry> getAllFormEntries(Site site)
618    {
619        String formEntryQuery = QueryHelper.getXPathQuery(null, "ametys:form-entry", null, null);
620        String xPathQuery = site != null
621            ? QueryHelper.getXPathQuery(site.getName(), "ametys:site", null, null) + formEntryQuery
622            : formEntryQuery;
623        AmetysObjectIterable<FormEntry> formEntries = _resolver.query(xPathQuery);
624        return formEntries.stream().collect(Collectors.toList());
625    }
626    
627    /**
628     * Get the form entries
629     * @param form the form
630     * @param onlyActiveEntries <code>true</code> to have only active entries
631     * @param sorts the list of sort
632     * @return the form entries
633     */
634    public List<FormEntry> getFormEntries(Form form, boolean onlyActiveEntries, List<Sort> sorts)
635    {
636        return getFormEntries(form, onlyActiveEntries, null, sorts);
637    }
638    
639    /**
640     * Get the form entries
641     * @param form the form
642     * @param onlyActiveEntries <code>true</code> to have only active entries
643     * @param additionalEntryFilterExpr the additional entry filter expression. Can be null.
644     * @param sorts the list of sort
645     * @return the form entries
646     */
647    public List<FormEntry> getFormEntries(Form form, boolean onlyActiveEntries, Expression additionalEntryFilterExpr, List<Sort> sorts)
648    {
649        try
650        {
651            String uuid = form.getNode().getIdentifier();
652            String xpathQuery = "//element(*, ametys:form)[@" + JcrConstants.JCR_UUID + " = '" + uuid + "']//element(*, ametys:form-entry)";
653            
654            String entryFilterQuery = _getEntryFilterQuery(onlyActiveEntries, additionalEntryFilterExpr);
655            if (onlyActiveEntries || StringUtils.isNotBlank(entryFilterQuery))
656            {
657                xpathQuery += "[" + entryFilterQuery + "]";
658            }
659            
660            String sortsAsString = "";
661            for (Sort sort : sorts)
662            {
663                if (StringUtils.isNotBlank(sortsAsString))
664                {
665                    sortsAsString += ", ";
666                }
667                
668                sortsAsString += "@ametys:" + sort.attributeName() + " " + sort.direction();
669            }
670            
671            if (StringUtils.isNotBlank(sortsAsString))
672            {
673                xpathQuery += " order by " + sortsAsString;
674            }
675            
676            AmetysObjectIterable<FormEntry> formEntries = _resolver.query(xpathQuery);
677            return formEntries.stream().collect(Collectors.toList());
678        }
679        catch (RepositoryException e)
680        {
681            getLogger().error("An error occurred getting entries of form '" + form.getId() + "'");
682        }
683        
684        return List.of();
685    }
686    
687    private String _getEntryFilterQuery(boolean onlyActiveEntries, Expression additionalEntryFilterExpr)
688    {
689        List<Expression> expressions = new ArrayList<>();
690        if (onlyActiveEntries)
691        {
692            expressions.add(new BooleanExpression(FormEntry.ATTRIBUTE_ACTIVE, true));
693        }
694        
695        if (additionalEntryFilterExpr != null)
696        {
697            expressions.add(additionalEntryFilterExpr);
698        }
699        
700        return new AndExpression(expressions).build();
701    }
702    
703    /**
704     * Search the form entries
705     * @param siteName the site name
706     * @param form the form
707     * @param onlyActiveEntries <code>true</code> to have only active entries. Active entries are used in waiting list.
708     * @param actionableOnly <code>true</code> to return only entry with at least one available action for the current user
709     * @param checkRights <code>true</code> to check rights for returned entries
710     * @param additionalEntryFilterQuery the additional entry filter query. Can be null.
711     * @param facets the list of facet
712     * @param sorts the list of sort
713     * @param offset the offset of the first result to return
714     * @param length the maximum number of results to return
715     * @return the form entries
716     * @throws Exception if an error occurred during search 
717     */
718    public SearchResults<FormEntry> searchFormEntries(String siteName, Form form, boolean onlyActiveEntries, boolean actionableOnly, boolean checkRights, Query additionalEntryFilterQuery, List<FacetDefinition> facets, List<SortDefinition> sorts, int offset, int length) throws Exception
719    {
720        List<Query> queries = new ArrayList<>();
721        if (form != null)
722        {
723            queries.add(new FormQuery(form.getId()));
724        }
725        
726        if (onlyActiveEntries)
727        {
728            queries.add(new BooleanQuery(FormEntry.ATTRIBUTE_ACTIVE, true));
729        }
730        
731        if (actionableOnly)
732        {
733            UserIdentity user = _currentUserProvider.getUser();
734            AndQuery actorsQuery = new AndQuery(
735                    new UsersQuery(FormEntry.SYSTEM_PROPERTY_WORKFLOW_DENIED_ACTORS + "_s", Operator.NE, user), // current user must not be in denied actors
736                    new OrQuery(
737                        new UsersQuery(FormEntry.SYSTEM_PROPERTY_WORKFLOW_ALLOWED_ACTORS + "_s", user), // current user mut be in allowed actors ...
738                        new BooleanQuery(FormEntry.SYSTEM_PROPERTY_ANY_CONNECTED_WORKFLOW_ACTORS, true) // ... or in any connected workflow actors
739                    )
740                );
741            queries.add(actorsQuery);
742        }
743        
744        if (additionalEntryFilterQuery != null)
745        {
746            queries.add(additionalEntryFilterQuery);
747        }
748        
749        queries.add(new NotQuery(new DateQuery(FormEntry.ATTRIBUTE_ANONYMIZATION_DATE)));
750        queries.add(new SiteQuery(siteName));
751        
752        Searcher searcher = _searcherFactory.create()
753                .withQuery(new AndQuery(queries))
754                .withFacets(facets)
755                .addFilterQuery(new DocumentTypeQuery(SolrFormEntryIndexer.TYPE_FORM_ENTRY))
756                .withSort(sorts)
757                .withLimits(offset, length)
758                .setCheckRights(checkRights);
759
760        return searcher.searchWithFacets();
761    }
762    
763    
764    /**
765     * Get all users who answer to the form as JSON
766     * @param formId the form id
767     * @return all users as JSON
768     */
769    @Callable (rights = HANDLE_FORMS_ENTRIES_RIGHT_ID, paramIndex = 0, rightContext = FormsDirectoryRightAssignmentContext.ID, decorators = ForceDefaultRepositoryWorkspaceCallableDecorator.DECORATOR_ID)
770    public List<Map<String, String>> getFormEntriesUsers(String formId)
771    {
772        Form form = _resolver.resolveById(formId);
773        List<FacetDefinition> facets = List.of(new FacetDefinition(FormEntry.ATTRIBUTE_USER, FormEntry.ATTRIBUTE_USER + "_s"));
774        
775        try
776        {
777            SearchResults<FormEntry> searchFormEntries = searchFormEntries(
778                    form.getSiteName(), 
779                    form, 
780                    false, // do not filter on active entries
781                    true, // only entries with at least one available action for the current user
782                    false, // do not check read rights
783                    null, 
784                    facets, 
785                    List.of(), 
786                    0, 
787                    Integer.MAX_VALUE
788            );
789            
790            return searchFormEntries.getFacetResults().get(FormEntry.ATTRIBUTE_USER)
791                    .keySet()
792                    .stream()
793                    .map(id -> UserIdentity.stringToUserIdentity(id))
794                    .map(_userManager::getUser)
795                    .filter(Objects::nonNull)
796                    .sorted(Comparator.comparing(User::getFullName, String.CASE_INSENSITIVE_ORDER))
797                    .map(u -> Map.of("text", u.getFullName(), "id", UserIdentity.userIdentityToString(u.getIdentity())))
798                    .toList();
799        }
800        catch (Exception e) 
801        {
802            getLogger().error("An error occurred while searching users who answer to form '" + formId + "'", e);
803            return List.of();
804        }
805    }
806    
807    /**
808     * Record representing a sort with form attribute name and direction
809     * @param attributeName the attribute name
810     * @param direction the direction
811     */
812    public record Sort(String attributeName, String direction) { /* */ }
813}