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