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