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 */
016package org.ametys.plugins.forms.dao;
017
018import java.util.ArrayList;
019import java.util.HashMap;
020import java.util.List;
021import java.util.Map;
022import java.util.Set;
023import java.util.stream.Collectors;
024
025import javax.jcr.Node;
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.jackrabbit.util.Text;
033
034import org.ametys.core.observation.Event;
035import org.ametys.core.observation.ObservationManager;
036import org.ametys.core.right.RightManager;
037import org.ametys.core.ui.Callable;
038import org.ametys.core.user.CurrentUserProvider;
039import org.ametys.core.user.UserIdentity;
040import org.ametys.core.util.I18nUtils;
041import org.ametys.plugins.forms.FormEvents;
042import org.ametys.plugins.forms.question.types.ChoicesListQuestionType;
043import org.ametys.plugins.forms.repository.CopyFormUpdater;
044import org.ametys.plugins.forms.repository.CopyFormUpdaterExtensionPoint;
045import org.ametys.plugins.forms.repository.Form;
046import org.ametys.plugins.forms.repository.FormPage;
047import org.ametys.plugins.forms.repository.FormPageFactory;
048import org.ametys.plugins.forms.repository.FormPageRule;
049import org.ametys.plugins.forms.repository.FormPageRule.PageRuleType;
050import org.ametys.plugins.forms.repository.FormQuestion;
051import org.ametys.plugins.forms.rights.FormsDirectoryRightAssignmentContext;
052import org.ametys.plugins.repository.AmetysObject;
053import org.ametys.plugins.repository.AmetysObjectResolver;
054import org.ametys.plugins.repository.AmetysRepositoryException;
055import org.ametys.plugins.repository.UnknownAmetysObjectException;
056import org.ametys.plugins.repository.jcr.DefaultTraversableAmetysObject;
057import org.ametys.plugins.repository.jcr.JCRAmetysObject;
058import org.ametys.plugins.repository.jcr.NameHelper;
059import org.ametys.runtime.i18n.I18nizableText;
060import org.ametys.runtime.plugin.component.AbstractLogEnabled;
061
062/**
063 * DAO for manipulating form pages
064 */
065public class FormPageDAO extends AbstractLogEnabled implements Serviceable, Component
066{
067    /** The Avalon role */
068    public static final String ROLE = FormPageDAO.class.getName();
069    /** Observer manager. */
070    protected ObservationManager _observationManager;
071    /** The Ametys object resolver */
072    protected AmetysObjectResolver _resolver;
073    /** The current user provider. */
074    protected CurrentUserProvider _currentUserProvider;
075    /** The form question DAO */
076    protected FormQuestionDAO _formQuestionDAO;
077    /** I18n Utils */
078    protected I18nUtils _i18nUtils;
079    /** The form DAO */
080    protected FormDAO _formDAO;
081    /** The right manager */
082    protected RightManager _rightManager;
083    /** The copy form updater extension point */
084    protected CopyFormUpdaterExtensionPoint _copyFormEP;
085    
086    public void service(ServiceManager manager) throws ServiceException
087    {
088        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
089        _observationManager = (ObservationManager) manager.lookup(ObservationManager.ROLE);
090        _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE);
091        _formQuestionDAO = (FormQuestionDAO) manager.lookup(FormQuestionDAO.ROLE);
092        _i18nUtils = (I18nUtils) manager.lookup(I18nUtils.ROLE);
093        _formDAO = (FormDAO) manager.lookup(FormDAO.ROLE);
094        _rightManager = (RightManager) manager.lookup(RightManager.ROLE);
095        _copyFormEP = (CopyFormUpdaterExtensionPoint) manager.lookup(CopyFormUpdaterExtensionPoint.ROLE);
096    }
097
098    /**
099     * Creates a {@link FormPage}.
100     * @param parentId The id of the parent.
101     * @param name The desired name for the new {@link FormPage}
102     * @return The id of the created form page
103     * @throws Exception if an error occurs during the form creation process
104     */
105    @Callable (rights = Callable.SKIP_BUILTIN_CHECK)
106    public Map<String, String> createPage (String parentId, String name) throws Exception
107    {
108        Map<String, String> result = new HashMap<>();
109        
110        Form rootNode = _resolver.resolveById(parentId);
111        _formDAO.checkHandleFormRight(rootNode);
112        
113        // Find unique name
114        String uniqueName = NameHelper.getUniqueAmetysObjectName(rootNode, name);
115        FormPage page = rootNode.createChild(uniqueName, FormPageFactory.FORM_PAGE_NODETYPE);
116        page.setTitle(uniqueName);
117        
118        rootNode.saveChanges();
119
120        Map<String, Object> eventParams = new HashMap<>();
121        eventParams.put("form", rootNode);
122        _observationManager.notify(new Event(FormEvents.FORM_MODIFIED, _getCurrentUser(), eventParams));
123        
124        
125        result.put("id", page.getId());
126        result.put("name", page.getTitle());
127        
128        return result;
129    }
130    
131    /**
132     * Rename a {@link FormPage}
133     * @param id The id of the page 
134     * @param newName The new name of the page
135     * @return A result map
136     */
137    @Callable (rights = Callable.SKIP_BUILTIN_CHECK)
138    public Map<String, String> renamePage (String id, String newName)
139    {
140        Map<String, String> results = new HashMap<>();
141        
142        FormPage page = _resolver.resolveById(id);
143        _formDAO.checkHandleFormRight(page);
144        
145        String legalName = Text.escapeIllegalJcrChars(newName);
146        Node node = page.getNode();
147        try
148        {
149            page.setTitle(newName);
150            
151            node.getSession().move(node.getPath(), node.getParent().getPath() + '/' + legalName);
152            node.getSession().save();
153            
154            Map<String, Object> eventParams = new HashMap<>();
155            eventParams.put("form", page.getForm());
156            _observationManager.notify(new Event(FormEvents.FORM_MODIFIED, _getCurrentUser(), eventParams));
157            
158            results.put("id", id);
159            results.put("newName", legalName);
160            results.put("formId", page.getForm().getId());
161        }
162        catch (RepositoryException e)
163        {
164            getLogger().error("Form renaming failed.", e);
165            results.put("message", "cannot-rename");
166        }
167        
168        return results;
169    }
170    
171    /**
172     * Deletes a {@link FormPage}.
173     * @param id The id of the page to delete
174     * @return The id of the page
175     */
176    @Callable (rights = Callable.SKIP_BUILTIN_CHECK)
177    public Map<String, String> deletePage (String id)
178    {
179        Map<String, String> result = new HashMap<>();
180        
181        FormPage page = _resolver.resolveById(id);
182        _formDAO.checkHandleFormRight(page);
183        
184        Form parent = page.getForm();
185        
186        //remove question rules references
187        _removeReferencesFromQuestionsRules(page, parent);
188        
189        page.remove();
190        
191        // Remove page rules references
192        _removeReferencesFromPages (id, parent);
193        _removeReferencesFromQuestions(id, parent);
194        parent.saveChanges();
195        
196        Map<String, Object> eventParams = new HashMap<>();
197        eventParams.put("form", parent);
198        _observationManager.notify(new Event(FormEvents.FORM_MODIFIED, _getCurrentUser(), eventParams));
199        
200        result.put("id", id);
201        result.put("formId", parent.getId());
202        
203        return result;
204    }
205    
206    /**
207     * Copies and pastes a form page.
208     * @param formId The id of the form, target of the copy
209     * @param pageId The id of the page to copy
210     * @return The id of the created page
211     */
212    @Callable (rights = Callable.SKIP_BUILTIN_CHECK)
213    public Map<String, String> copyPage(String formId, String pageId)
214    {
215        Map<String, String> result = new HashMap<>();
216        
217        Form parentForm = _resolver.resolveById(formId);
218        _formDAO.checkHandleFormRight(parentForm);
219        
220        FormPage originalPage = _resolver.resolveById(pageId);
221        
222        // Find unique name
223        String uniqueName = NameHelper.getUniqueAmetysObjectName(parentForm, originalPage.getName());
224        FormPage cPage = originalPage.copyTo(parentForm, uniqueName);
225        
226        String copyTitle = _i18nUtils.translate(new I18nizableText("plugin.forms", "PLUGIN_FORMS_TREE_COPY_NAME_PREFIX")) + originalPage.getTitle();
227        cPage.setTitle(copyTitle);
228        cPage.deleteRule();
229        
230        parentForm.saveChanges();
231
232        for (String epId : _copyFormEP.getExtensionsIds())
233        {
234            CopyFormUpdater copyFormUpdater = _copyFormEP.getExtension(epId);
235            copyFormUpdater.updateFormPage(originalPage, cPage);
236        }
237        
238        Map<String, Object> eventParams = new HashMap<>();
239        eventParams.put("form", parentForm);
240        _observationManager.notify(new Event(FormEvents.FORM_MODIFIED, _getCurrentUser(), eventParams));
241        
242        result.put("id", cPage.getId());
243        result.put("formId", parentForm.getId());
244        
245        return result;
246    }
247    
248    /**
249     * Moves an element of the form.
250     * @param id The id of the element to move.
251     * @param oldParent The id of the element's parent.
252     * @param newParent The id of the new element's parent.
253     * @param index The index where to move. null to place the element at the end.
254     * @return A map with the ids of the element, the old parent and the new parent
255     * @throws Exception if an error occurs when moving an element of the form
256     */
257    @Callable (rights = Callable.SKIP_BUILTIN_CHECK)
258    public Map<String, String> moveObject (String id, String oldParent, String newParent, long index) throws Exception
259    {
260        Map<String, String> result = new HashMap<>();
261        
262        JCRAmetysObject aoMoved = _resolver.resolveById(id);
263        _formDAO.checkHandleFormRight(aoMoved);
264        
265        DefaultTraversableAmetysObject newParentAO = _resolver.resolveById(newParent);
266        JCRAmetysObject  brother = null;
267        long size = newParentAO.getChildren().getSize();
268        if (index != -1 && index < size)
269        {
270            brother = newParentAO.getChildAt(index);
271        }
272        else if (index >= size)
273        {
274            brother = newParentAO.getChildAt(Math.toIntExact(size) - 1);
275        }
276        Form oldForm = getParentForm(aoMoved);
277        if (oldForm != null)
278        {
279            result.put("oldFormId", oldForm.getId());
280        }
281        
282        if (oldParent.equals(newParent) && brother != null)
283        {
284            Node node = aoMoved.getNode();
285            String name = "";
286            try
287            {
288                name = (index == size)
289                            ? null 
290                            : brother.getName();
291                node.getParent().orderBefore(node.getName(), name);
292            }
293            catch (RepositoryException e)
294            {
295                throw new AmetysRepositoryException(String.format("Unable to order AmetysOject '%s' before sibling '%s'", this, name), e);
296            }
297        }
298        else
299        {
300            Node node = aoMoved.getNode();
301            
302            String name = node.getName();
303            // Find unused name on new parent node
304            int localIndex = 2;
305            while (newParentAO.hasChild(name))
306            {
307                name = node.getName() + "-" + localIndex++;
308            }
309            
310            node.getSession().move(node.getPath(), newParentAO.getNode().getPath() + "/" + name);
311            
312            if (brother != null)
313            {
314                node.getParent().orderBefore(node.getName(), brother.getName());
315            }
316        }
317        
318        if (newParentAO.needsSave())
319        {
320            newParentAO.saveChanges();
321        }
322        
323        Form form = getParentForm(aoMoved);
324        if (form != null)
325        {
326            result.put("newFormId", form.getId());
327            
328            Map<String, Object> eventParams = new HashMap<>();
329            eventParams.put("form", form);
330            _observationManager.notify(new Event(FormEvents.FORM_MODIFIED, _getCurrentUser(), eventParams));
331        }
332        
333        result.put("id", id);
334        
335        if (aoMoved instanceof FormPage)
336        {
337            result.put("type", "page");
338        }
339        else if (aoMoved instanceof FormQuestion)
340        {
341            result.put("type", "question");
342            result.put("questionType", ((FormQuestion) aoMoved).getType().getId());
343        }
344        
345        result.put("newParentId", newParentAO.getId());
346        result.put("oldParentId", oldParent);
347        
348        return result;
349    }
350    
351    /**
352     * Get the page's properties
353     * @param pageId The form page's id
354     * @return The page properties
355     */
356    @Callable  (rights = Callable.SKIP_BUILTIN_CHECK)
357    public Map<String, Object> getPageProperties (String pageId)
358    {
359        try
360        {
361            FormPage page = _resolver.resolveById(pageId);
362            return getPageProperties(page, true);
363        }
364        catch (UnknownAmetysObjectException e)
365        {
366            getLogger().warn("Can't find page with id: {}. It probably has just been deleted", pageId, e);
367            Map<String, Object> infos = new HashMap<>();
368            infos.put("id", pageId);
369            return infos;
370        }
371    }
372    
373    /**
374     * Get the page's properties
375     * @param page The form page
376     * @param withRights <code>true</code> to have the rights in the properties
377     * @return The page properties
378     */
379    public Map<String, Object> getPageProperties (FormPage page, boolean withRights)
380    {
381        Map<String, Object> infos = new HashMap<>();
382        
383        List<String> fullPath = new ArrayList<>();
384        fullPath.add(page.getTitle());
385
386        AmetysObject node = page.getParent();
387        fullPath.add(0, node.getName()); 
388        
389        infos.put("type", "page");
390        
391        /** Use in the bus message */
392        infos.put("id", page.getId());
393        infos.put("title", page.getTitle());
394        infos.put("formId", page.getForm().getId());
395        infos.put("hasEntries", !page.getForm().getEntries().isEmpty());
396        infos.put("hasChildren", page.getQuestions().size() > 0);
397        
398        boolean isConfigured = !page.getQuestions().stream().anyMatch(q -> !q.getType().isQuestionConfigured(q));
399        infos.put("isConfigured", isConfigured);
400        
401        if (withRights)
402        {
403            infos.put("rights", _getUserRights(page));
404        }
405        else
406        {
407            infos.put("canWrite", _formDAO.hasWriteRightOnForm(_currentUserProvider.getUser(), page));
408        }
409
410        return infos;
411    }
412    
413    /**
414     * Get user rights for the given form page
415     * @param page the form page
416     * @return the set of rights
417     */
418    protected Set<String> _getUserRights (FormPage page)
419    {
420        UserIdentity user = _currentUserProvider.getUser();
421        return _rightManager.getUserRights(user, page);
422    }
423    
424    /**
425     * Gets the ids of the path elements of a form component, i.e. the parent ids.
426     * <br>For instance, if the page path is 'a/b/c', then the result list will be ["id-of-a", "id-of-b", "id-of-c"]
427     * @param id The id of the form component
428     * @return the ids of the path elements of a form
429     */
430    @Callable (rights = FormDAO.HANDLE_FORMS_RIGHT_ID, rightContext = FormsDirectoryRightAssignmentContext.ID, paramIndex = 0)
431    public List<String> getIdsOfPath(String id)
432    {
433        AmetysObject formComponent = _resolver.resolveById(id);
434        
435        if (!(formComponent instanceof FormPage) && !(formComponent instanceof FormQuestion))
436        {
437            throw new IllegalArgumentException("The given id is not a form component");
438        }
439        
440        List<String> pathElements = new ArrayList<>();
441        AmetysObject current = formComponent.getParent();
442        while (!(current instanceof Form))
443        {
444            pathElements.add(0, current.getId());
445            current = current.getParent();
446        }
447        
448        return pathElements;
449    }
450    
451    /**
452     * Gets all pages for given parent
453     * @param formId The id of the {@link Form}, defining the context from which getting children
454     * @return all forms  for given parent
455     */
456    @Callable (rights = Callable.SKIP_BUILTIN_CHECK)
457    public List<Map<String, Object>> getChildPages(String formId)
458    {
459        Form form = _resolver.resolveById(formId);
460        _formDAO.checkHandleFormRight(form);
461        
462        return form.getPages()
463                    .stream()
464                    .map(p -> this.getPageProperties(p, false))
465                    .toList();
466    }
467    
468    /**
469     * Get the form containing the given object.
470     * @param obj the object.
471     * @return the parent Form.
472     */
473    protected Form getParentForm(JCRAmetysObject obj)
474    {
475        try
476        {
477            JCRAmetysObject currentAo = obj.getParent();
478            
479            while (!(currentAo instanceof Form))
480            {
481                currentAo = currentAo.getParent();
482            }
483            
484            if (currentAo instanceof Form)
485            {
486                return (Form) currentAo;
487            }
488        }
489        catch (AmetysRepositoryException e)
490        {
491            // Ignore, just return null.
492        }
493        
494        return null;
495    }
496    
497    /**
498     * Determines if a page is the last of form's pages.
499     * @param id The page id
500     * @return True if the page is the last one. 
501     */
502    @Callable (rights = Callable.SKIP_BUILTIN_CHECK)
503    public boolean isLastPage (String id)
504    {
505        FormPage page = _resolver.resolveById(id);
506        _formDAO.checkHandleFormRight(page);
507        
508        Form form = page.getForm();
509        
510        List<FormPage> pages = form.getPages();
511        FormPage lastPage = pages.get(pages.size() - 1);
512        
513        return id.equals(lastPage.getId());
514    }
515    
516    /**
517     * Gets the branches for a form page.
518     * @param pageId The id of the form page.
519     * @return The branches
520     */
521    @Callable (rights = Callable.SKIP_BUILTIN_CHECK)
522    public Map<String, Object> getBranches (String pageId)
523    {
524        Map<String, Object> result = new HashMap<>();
525        
526        FormPage page = _resolver.resolveById(pageId);
527        _formDAO.checkHandleFormRight(page);
528        
529        result.put("id", pageId);
530        
531        List<Object> questions = new ArrayList<>();
532        List<FormQuestion> questionsAO = page.getQuestions();
533        int index = 1;
534        for (FormQuestion question : questionsAO)
535        {
536            if (question.getType() instanceof ChoicesListQuestionType type 
537                && !type.getSourceType(question).remoteData()
538                && !question.isModifiable())
539            {
540                try
541                {
542                    questions.add(_formQuestionDAO.getRules(question.getId(), index));
543                }
544                catch (Exception e)
545                {
546                    getLogger().error("an exception occured while getting rules for question " + question.getId());
547                }
548            }
549            index++;
550        }
551        result.put("questions", questions);
552        
553        // SAX page rule
554        result.put("rule", getRule(pageId));
555        
556        return result;
557    }
558    
559    /**
560     * Gets the rule for a form page.
561     * @param id The id of the form page.
562     * @return The rule, or null
563     */
564    @Callable (rights = Callable.SKIP_BUILTIN_CHECK)
565    public Map<String, Object> getRule (String id)
566    {
567        Map<String, Object> result = new HashMap<>();
568        
569        FormPage page = _resolver.resolveById(id);
570        _formDAO.checkHandleFormRight(page);
571        
572        FormPageRule rule = page.getRule();
573        
574        if (rule != null)
575        {
576            result.put("type", rule.getType().name());
577            String pageId = rule.getPageId();
578            if (pageId != null)
579            {
580                try
581                {
582                    FormPage pageAO = _resolver.resolveById(pageId);
583                    result.put("page", pageId);
584                    result.put("pageName", pageAO.getTitle());
585                }
586                catch (UnknownAmetysObjectException e)
587                {
588                    // The page does not exist anymore
589                }
590            }
591        }
592        else
593        {
594            result = null;
595        }
596        
597        return result;
598    }
599    
600    /**
601     * Adds a a new rule to a page.
602     * @param id The id of the page
603     * @param rule The rule type
604     * @param page The page to jump or skip
605     * @return An empty map
606     */
607    @Callable (rights = Callable.SKIP_BUILTIN_CHECK)
608    public Map<String, Object> addRule (String id, String rule, String page)
609    {
610        FormPage formPage = _resolver.resolveById(id);
611        _formDAO.checkHandleFormRight(formPage);
612        
613        formPage.setRule(PageRuleType.valueOf(rule), page);
614        formPage.saveChanges();
615        
616        Map<String, Object> eventParams = new HashMap<>();
617        eventParams.put("form", formPage.getForm());
618        _observationManager.notify(new Event(FormEvents.FORM_MODIFIED, _getCurrentUser(), eventParams));
619        
620        return new HashMap<>();
621    }
622
623    /**
624     * Deletes a rule to a page
625     * @param id The id of the page
626     * @return An empty map
627     */
628    @Callable (rights = Callable.SKIP_BUILTIN_CHECK)
629    public Map<String, Object> deleteRule (String id)
630    {
631        FormPage formPage = _resolver.resolveById(id);
632        _formDAO.checkHandleFormRight(formPage);
633        
634        formPage.deleteRule();
635        formPage.saveChanges();
636        
637        Map<String, Object> eventParams = new HashMap<>();
638        eventParams.put("form", formPage.getForm());
639        _observationManager.notify(new Event(FormEvents.FORM_MODIFIED, _getCurrentUser(), eventParams));
640        
641        return new HashMap<>();
642    }
643    
644    private void _removeReferencesFromPages (String pageId, Form parent)
645    {
646        List<FormPageRule> rulesWithPageId = parent.getPages().stream()
647            .map(page -> page.getRule())
648            .filter(rule -> rule != null && pageId.equals(rule.getPageId()))
649            .collect(Collectors.toList());
650        
651        for (FormPageRule rule : rulesWithPageId)
652        {
653            rule.remove();
654        }
655        parent.saveChanges();
656    }
657    
658    private void _removeReferencesFromQuestions (String pageId, Form parent)
659    {
660        List<FormPageRule> rulesWithPageId = parent.getQuestions().stream()
661            .map(question -> question.getPageRules())
662            .flatMap(List::stream)
663            .filter(rule -> rule != null && pageId.equals(rule.getPageId()))
664            .collect(Collectors.toList());
665            
666        for (FormPageRule rule : rulesWithPageId)
667        {
668            rule.remove();
669        }
670        parent.saveChanges();
671    }
672    
673    private void _removeReferencesFromQuestionsRules (FormPage page, Form parent)
674    {
675        for (FormQuestion questionToDelete : page.getQuestions())
676        {
677            parent.deleteQuestionsRule(questionToDelete.getId());
678        }
679    }
680    
681    /**
682     * Provides the current user.
683     * @return the user which cannot be <code>null</code>.
684     */
685    protected UserIdentity _getCurrentUser()
686    {      
687        return _currentUserProvider.getUser();
688    }
689}