001/*
002 *  Copyright 2016 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.workspaces.tasks;
017
018import java.time.LocalDate;
019import java.time.ZonedDateTime;
020import java.time.format.DateTimeFormatter;
021import java.util.ArrayList;
022import java.util.HashMap;
023import java.util.List;
024import java.util.Map;
025import java.util.Optional;
026import java.util.stream.Collectors;
027
028import org.apache.avalon.framework.service.ServiceException;
029import org.apache.avalon.framework.service.ServiceManager;
030import org.apache.cocoon.servlet.multipart.Part;
031import org.apache.commons.lang3.StringUtils;
032
033import org.ametys.cms.repository.comment.Comment;
034import org.ametys.core.observation.Event;
035import org.ametys.core.ui.Callable;
036import org.ametys.core.user.User;
037import org.ametys.core.user.UserIdentity;
038import org.ametys.plugins.repository.AmetysRepositoryException;
039import org.ametys.plugins.repository.ModifiableTraversableAmetysObject;
040import org.ametys.plugins.workspaces.ObservationConstants;
041import org.ametys.plugins.workspaces.members.ProjectMemberManager;
042import org.ametys.plugins.workspaces.project.objects.Project;
043import org.ametys.plugins.workspaces.tags.ProjectTagProviderExtensionPoint;
044import org.ametys.plugins.workspaces.tasks.Task.CheckItem;
045import org.ametys.plugins.workspaces.tasks.jcr.JCRTask;
046import org.ametys.plugins.workspaces.tasks.jcr.JCRTaskFactory;
047import org.ametys.plugins.workspaces.tasks.json.TaskJSONHelper;
048import org.ametys.runtime.authentication.AccessDeniedException;
049
050/**
051 * DAO for interacting with tasks of a project
052 */
053public class WorkspaceTaskDAO extends AbstractWorkspaceTaskDAO
054{
055    /** The Avalon role */
056    public static final String ROLE = WorkspaceTaskDAO.class.getName();
057    
058    /** The project member manager */
059    protected ProjectMemberManager _projectMemberManager;
060    
061    /** The tag provider extension point */
062    protected ProjectTagProviderExtensionPoint _tagProviderExtPt;
063        
064    /** The task JSON helper */
065    protected TaskJSONHelper _taskJSONHelper;
066    
067    /** The task list DAO */
068    protected WorkspaceTasksListDAO _workspaceTasksListDAO;
069    
070    @Override
071    public void service(ServiceManager manager) throws ServiceException
072    {
073        super.service(manager);
074        _projectMemberManager = (ProjectMemberManager) manager.lookup(ProjectMemberManager.ROLE);
075        _tagProviderExtPt = (ProjectTagProviderExtensionPoint) manager.lookup(ProjectTagProviderExtensionPoint.ROLE);
076        _taskJSONHelper = (TaskJSONHelper) manager.lookup(TaskJSONHelper.ROLE);
077        _workspaceTasksListDAO = (WorkspaceTasksListDAO) manager.lookup(WorkspaceTasksListDAO.ROLE);
078    }
079
080    /**
081     * Get the tasks from project
082     * @return the list of tasks
083     */
084    @Callable (rights = Callable.CHECKED_BY_IMPLEMENTATION)
085    public List<Map<String, Object>> getTasks()
086    {
087        Project project = _workspaceHelper.getProjectFromRequest();
088        
089        if (!_projectRightHelper.hasReadAccessOnModule(project, TasksWorkspaceModule.TASK_MODULE_ID))
090        {
091            throw new AccessDeniedException("User '" + _currentUserProvider.getUser() + "' tried to get tasks without reader right");
092        }
093        
094        List<Map<String, Object>> tasksInfo = new ArrayList<>();
095        for (Task task : getProjectTasks(project))
096        {
097            tasksInfo.add(_taskJSONHelper.taskAsJSON(task, getSitemapLanguage(), getSiteName()));
098        }
099        
100        return tasksInfo;
101    }
102    
103    /**
104     * Add a new task to the tasks list
105     * @param tasksListId the tasks list id
106     * @param parameters The task parameters
107     * @param newFiles the files to add
108     * @param newFileNames the file names to add
109     * @return The task data
110     */
111    @Callable (rights = Callable.CHECKED_BY_IMPLEMENTATION)
112    public Map<String, Object> addTask(String tasksListId, Map<String, Object> parameters, List<Part> newFiles, List<String> newFileNames)
113    {
114        Project project = _workspaceHelper.getProjectFromRequest();
115        
116        if (StringUtils.isBlank(tasksListId))
117        {
118            throw new IllegalArgumentException("Tasks list id is mandatory to create a new task");
119        }
120        
121        ModifiableTraversableAmetysObject tasksRoot = _getTasksRoot(project, true);
122        
123        _checkUserRights(tasksRoot, RIGHTS_HANDLE_TASK);
124
125        int index = 1;
126        String name = "task-1";
127        while (tasksRoot.hasChild(name))
128        {
129            index++;
130            name = "task-" + index;
131        }
132        
133        JCRTask task = (JCRTask) tasksRoot.createChild(name, JCRTaskFactory.TASK_NODETYPE);
134        task.setTasksListId(tasksListId);
135        task.setPosition(Long.valueOf(_workspaceTasksListDAO.getChildTask(tasksListId).size()));
136        
137        ZonedDateTime now = ZonedDateTime.now();
138        task.setCreationDate(now);
139        task.setLastModified(now);
140        task.setAuthor(_currentUserProvider.getUser());
141
142        Map<String, Object> attributesResults = _setTaskAttributes(task, parameters, newFiles, newFileNames, new ArrayList<>());
143        
144        tasksRoot.saveChanges();
145        
146        Map<String, Object> eventParams = new HashMap<>();
147        eventParams.put(ObservationConstants.ARGS_TASK, task);
148        eventParams.put(org.ametys.plugins.explorer.ObservationConstants.ARGS_ID, task.getId());
149        
150        _observationManager.notify(new Event(ObservationConstants.EVENT_TASK_CREATED, _currentUserProvider.getUser(), eventParams));
151
152        Map<String, Object> results = new HashMap<>();
153        results.put("task", _taskJSONHelper.taskAsJSON(task, getSitemapLanguage(), getSiteName()));
154        results.putAll(attributesResults);
155        return results;
156    }
157    
158    /**
159     * Edit a task
160     * @param taskId The id of the task to edit
161     * @param parameters The JS parameters
162     * @param newFiles the new file to add
163     * @param newFileNames the file names to add
164     * @param deleteFiles the file to delete
165     * @return The task data
166     */
167    @Callable (rights = Callable.CHECKED_BY_IMPLEMENTATION)
168    public Map<String, Object> editTask(String taskId, Map<String, Object> parameters, List<Part> newFiles, List<String> newFileNames, List<String> deleteFiles)
169    {
170        JCRTask task = _resolver.resolveById(taskId);
171        
172        ModifiableTraversableAmetysObject tasksRoot = task.getParent();
173        
174        _checkUserRights(tasksRoot, RIGHTS_HANDLE_TASK);
175        
176        Map<String, Object> attributesResults = _setTaskAttributes(task, parameters, newFiles, newFileNames, deleteFiles);
177        task.setLastModified(ZonedDateTime.now());
178        task.saveChanges();
179            
180        Map<String, Object> eventParams = new HashMap<>();
181        eventParams.put(ObservationConstants.ARGS_TASK, task);
182        eventParams.put(org.ametys.plugins.explorer.ObservationConstants.ARGS_ID, taskId);
183        
184        _observationManager.notify(new Event(ObservationConstants.EVENT_TASK_UPDATED, _currentUserProvider.getUser(), eventParams));
185        
186        // Closed status has changed
187        if (attributesResults.containsKey("isClosed"))
188        {
189            _observationManager.notify(new Event(ObservationConstants.EVENT_TASK_CLOSED_STATUS_CHANGED, _currentUserProvider.getUser(), eventParams));
190        }
191
192        // Assigments have changed
193        if (attributesResults.containsKey("changedAssignments"))
194        {
195            _observationManager.notify(new Event(ObservationConstants.EVENT_TASK_ASSIGNED, _currentUserProvider.getUser(), eventParams));
196        }
197         
198        Map<String, Object> results = new HashMap<>();
199        results.put("task", _taskJSONHelper.taskAsJSON(task, getSitemapLanguage(), getSiteName()));
200        results.putAll(attributesResults);
201        return results;
202    }
203    
204    /**
205     * Move task to new position
206     * @param tasksListId the tasks list id
207     * @param taskId the task id to move
208     * @param newPosition the new position
209     * @return The task data
210     */
211    @Callable (rights = Callable.CHECKED_BY_IMPLEMENTATION)
212    public Map<String, Object> moveTask(String tasksListId, String taskId, long newPosition)
213    {
214        Task task = _resolver.resolveById(taskId);
215        
216        ModifiableTraversableAmetysObject tasksRoot = task.getParent();
217        _checkUserRights(task.getParent(), RIGHTS_HANDLE_TASK);
218        
219        if (tasksListId != task.getTaskListId())
220        {
221            List<Task> childTasks = _workspaceTasksListDAO.getChildTask(task.getTaskListId());
222            long position = 0;
223            for (Task childTask : childTasks)
224            {
225                if (!childTask.getId().equals(taskId))
226                {
227                    childTask.setPosition(position);
228                    position++;
229                }
230            }
231        }
232        
233        task.setTasksListId(tasksListId);
234        List<Task> childTasks = _workspaceTasksListDAO.getChildTask(tasksListId);
235        int size = childTasks.size();
236        if (newPosition > size)
237        {
238            throw new IllegalArgumentException("New position (" + newPosition + ") can't be greater than tasks child size (" + size + ")");
239        }
240        
241        long position = 0;
242        task.setPosition(newPosition);
243        for (Task childTask : childTasks)
244        {
245            if (position == newPosition)
246            {
247                position++;
248            }
249            
250            if (childTask.getId().equals(taskId))
251            {
252                childTask.setPosition(newPosition);
253            }
254            else
255            {
256                childTask.setPosition(position);
257                position++;
258            }
259        }
260        
261        tasksRoot.saveChanges();
262        
263        return _taskJSONHelper.taskAsJSON(task, getSitemapLanguage(), getSiteName());
264    }
265    
266    /**
267     * Remove a task
268     * @param taskId the task id to remove
269     * @return The task data
270     */
271    @Callable (rights = Callable.CHECKED_BY_IMPLEMENTATION)
272    public Map<String, Object> deleteTask(String taskId)
273    {
274        Task task = _resolver.resolveById(taskId);
275        
276        // Check user right
277        ModifiableTraversableAmetysObject tasksRoot = task.getParent();
278        _checkUserRights(tasksRoot, RIGHTS_DELETE_TASK);
279        
280        Map<String, Object> results = new HashMap<>();
281        
282        Map<String, Object> eventParams = new HashMap<>();
283        eventParams.put(ObservationConstants.ARGS_TASK, task);
284        eventParams.put(org.ametys.plugins.explorer.ObservationConstants.ARGS_ID, taskId);
285        _observationManager.notify(new Event(ObservationConstants.EVENT_TASK_DELETING, _currentUserProvider.getUser(), eventParams));
286        
287        String tasksListId = task.getTaskListId();
288        task.remove();
289        
290        // Reorder tasks position
291        long position = 0;
292        for (Task childTask : _workspaceTasksListDAO.getChildTask(tasksListId))
293        {
294            childTask.setPosition(position);
295            position++;
296        }
297        
298        tasksRoot.saveChanges();
299
300        eventParams = new HashMap<>();
301        eventParams.put(org.ametys.plugins.explorer.ObservationConstants.ARGS_ID, taskId);
302        _observationManager.notify(new Event(ObservationConstants.EVENT_TASK_DELETED, _currentUserProvider.getUser(), eventParams));
303
304        return results;
305    }
306    
307    /**
308     * Comment a task
309     * @param taskId the task id
310     * @param commentText the comment text
311     * @return The task data
312     */
313    @Callable (rights = Callable.CHECKED_BY_IMPLEMENTATION)
314    public Map<String, Object> commentTask(String taskId, String commentText)
315    {
316        Task task = _resolver.resolveById(taskId);
317        
318        ModifiableTraversableAmetysObject tasksRoot = task.getParent();
319        
320        _checkUserRights(tasksRoot, RIGHTS_COMMENT_TASK);
321        
322        Comment comment = createComment(task, commentText, tasksRoot);
323
324        // Notify listeners
325        Map<String, Object> eventParams = new HashMap<>();
326        eventParams.put(org.ametys.plugins.explorer.ObservationConstants.ARGS_ID, task.getId());
327        eventParams.put(ObservationConstants.ARGS_TASK_COMMENT_ID, comment.getId());
328        UserIdentity currentUser = _currentUserProvider.getUser();
329        _observationManager.notify(new Event(org.ametys.plugins.workspaces.ObservationConstants.EVENT_TASK_COMMENTED, currentUser, eventParams));
330        
331        
332        return _taskJSONHelper.taskAsJSON(task, getSitemapLanguage(), getSiteName());
333    }
334    
335    /**
336     * Edit a task comment
337     * @param taskId the task id
338     * @param commentId the comment Id
339     * @param commentText the comment text
340     * @return The task data
341     */
342    @Callable (rights = Callable.CHECKED_BY_IMPLEMENTATION)
343    public Map<String, Object> editCommentTask(String taskId, String commentId, String commentText)
344    {
345        Task task = _resolver.resolveById(taskId);
346        
347        ModifiableTraversableAmetysObject tasksRoot = task.getParent();
348
349        _checkUserRights(tasksRoot, RIGHTS_COMMENT_TASK);
350        
351        editComment(task, commentId, commentText, tasksRoot);
352        
353        return _taskJSONHelper.taskAsJSON(task, getSitemapLanguage(), getSiteName());
354    }
355    
356    /**
357     * Answer to a task's comment
358     * @param taskId the task id
359     * @param commentId the comment id
360     * @param commentText the comment text
361     * @return The task data
362     */
363    @Callable (rights = Callable.CHECKED_BY_IMPLEMENTATION)
364    public Map<String, Object> answerCommentTask(String taskId, String commentId, String commentText)
365    {
366        Task task = _resolver.resolveById(taskId);
367        
368        ModifiableTraversableAmetysObject tasksRoot = task.getParent();
369        
370        _checkUserRights(tasksRoot, RIGHTS_COMMENT_TASK);
371        
372        Comment comment = answerComment(task, commentId, commentText, tasksRoot);
373
374        // Notify listeners
375        Map<String, Object> eventParams = new HashMap<>();
376        eventParams.put(org.ametys.plugins.explorer.ObservationConstants.ARGS_ID, task.getId());
377        eventParams.put(ObservationConstants.ARGS_TASK_COMMENT_ID, comment.getId());
378        UserIdentity currentUser = _currentUserProvider.getUser();
379        _observationManager.notify(new Event(org.ametys.plugins.workspaces.ObservationConstants.EVENT_TASK_COMMENTED, currentUser, eventParams));
380        
381        return _taskJSONHelper.taskAsJSON(task, getSitemapLanguage(), getSiteName());
382    }
383    
384    /**
385     * Delete a task's comment
386     * @param taskId the task id
387     * @param commentId the comment id
388     * @return The task data
389     */
390    @Callable (rights = Callable.CHECKED_BY_IMPLEMENTATION)
391    public Map<String, Object> deleteCommentTask(String taskId, String commentId)
392    {
393        Task task = _resolver.resolveById(taskId);
394        
395        // Check user right
396        ModifiableTraversableAmetysObject tasksRoot = task.getParent();
397        
398        UserIdentity userIdentity = _currentUserProvider.getUser();
399        User user = _userManager.getUser(userIdentity);
400        
401        Comment comment = task.getComment(commentId);
402        String authorEmail = comment.getAuthorEmail();
403        if (!authorEmail.equals(user.getEmail()))
404        {
405            _checkUserRights(tasksRoot, RIGHTS_COMMENT_TASK);
406        }
407        
408        deleteComment(task, commentId, tasksRoot);
409              
410        return _taskJSONHelper.taskAsJSON(task, getSitemapLanguage(), getSiteName());
411    }
412    
413    /**
414     * Like or unlike a task's comment
415     * @param taskId the task id
416     * @param commentId the comment id
417     * @param liked true if the comment is liked, otherwise the comment is unliked
418     * @return The task data
419     */
420    @Callable (rights = Callable.CHECKED_BY_IMPLEMENTATION)
421    public Map<String, Object> likeOrUnlikeCommentTask(String taskId, String commentId, Boolean liked)
422    {
423        Task task = _resolver.resolveById(taskId);
424        
425        ModifiableTraversableAmetysObject tasksRoot = task.getParent();
426        
427        _checkUserRights(tasksRoot, RIGHTS_COMMENT_TASK);
428        
429        likeOrUnlikeComment(task, commentId, liked, tasksRoot);
430        
431        return _taskJSONHelper.taskAsJSON(task, getSitemapLanguage(), getSiteName());
432    }
433    
434    /**
435     * Set task's attributes
436     * @param task The task to edit
437     * @param parameters The JS parameters
438     * @param newFiles the new file to add to the task
439     * @param newFileNames the new file names to add to the task
440     * @param deleteFiles the file to remove from the task
441     * @return the map of results
442     */
443    protected Map<String, Object> _setTaskAttributes(JCRTask task, Map<String, Object> parameters, List<Part> newFiles, List<String> newFileNames, List<String> deleteFiles)
444    {
445        Map<String, Object> results = new HashMap<>();
446        
447        String label = (String) parameters.get(JCRTask.ATTRIBUTE_LABEL);
448        task.setLabel(label);
449
450        String description = (String) parameters.get(JCRTask.ATTRIBUTE_DESCRIPTION);
451        task.setDescription(description);
452        
453        _setTaskDates(task, parameters);
454        _setTaskCloseInfo(task, parameters, results);
455        _setAttachments(task, newFiles, newFileNames, deleteFiles);
456        
457        @SuppressWarnings("unchecked")
458        List<Map<String, Object>> assignmentIds = (List<Map<String, Object>>) parameters.getOrDefault(JCRTask.ATTRIBUTE_ASSIGNMENTS, new ArrayList<>());
459        List<UserIdentity> users = assignmentIds.stream()
460            .map(m -> (String) m.get("id"))
461            .map(UserIdentity::stringToUserIdentity)
462            .collect(Collectors.toList());
463        
464        if (!task.getAssignments().equals(users))
465        {
466            task.setAssignments(users);
467            results.put("changedAssignments", true);
468        }
469        
470        @SuppressWarnings("unchecked")
471        List<Map<String, Object>> checkListItems = (List<Map<String, Object>>) parameters.getOrDefault(JCRTask.ATTRIBUTE_CHECKLIST, new ArrayList<>());
472        List<CheckItem> checkItems = checkListItems.stream()
473            .map(e -> new CheckItem((String) e.get(JCRTask.ATTRIBUTE_CHECKLIST_LABEL), (boolean) e.get(JCRTask.ATTRIBUTE_CHECKLIST_ISCHECKED)))
474            .collect(Collectors.toList());
475        task.setCheckListItem(checkItems);
476
477        @SuppressWarnings("unchecked")
478        List<Object> tags = (List<Object>) parameters.getOrDefault(JCRTask.ATTRIBUTE_TAGS, new ArrayList<>());
479
480        List<Map<String, Object>> createdTagsJson = _handleTags(task, tags);
481        
482        results.put("newTags", createdTagsJson);
483        return results;
484    }
485    
486    private void _setTaskDates(JCRTask task, Map<String, Object> parameters)
487    {
488        String startDateAsStr = (String) parameters.get(JCRTask.ATTRIBUTE_STARTDATE);
489        LocalDate startDate = Optional.ofNullable(startDateAsStr)
490                .map(date -> LocalDate.parse(date, DateTimeFormatter.ISO_LOCAL_DATE))
491                .orElse(null);
492        task.setStartDate(startDate);
493        
494        String dueDateAsStr = (String) parameters.get(JCRTask.ATTRIBUTE_DUEDATE);
495        LocalDate dueDate = Optional.ofNullable(dueDateAsStr)
496                .map(date -> LocalDate.parse(date, DateTimeFormatter.ISO_LOCAL_DATE))
497                .orElse(null);
498        task.setDueDate(dueDate);
499    }
500    
501    private void _setTaskCloseInfo(JCRTask task, Map<String, Object> parameters, Map<String, Object> results)
502    {
503        @SuppressWarnings("unchecked")
504        Map<String, Object> closeInfo = (Map<String, Object>) parameters.get("closeInfo");
505        if (closeInfo != null && !task.isClosed())
506        {
507            task.close(true);
508            task.setCloseAuthor(_currentUserProvider.getUser());
509            task.setCloseDate(LocalDate.now());
510
511            results.put("isClosed", true);
512        }
513        else if (closeInfo == null && task.isClosed())
514        {
515            task.close(false);
516            task.setCloseAuthor(null);
517            task.setCloseDate(null);
518
519            results.put("isClosed", false);
520        }
521    }
522    
523    /**
524     * Get all tasks from given projets
525     * @param project the project
526     * @return All tasks as JSON
527     */
528    public List<Task> getProjectTasks(Project project)
529    {
530        TasksWorkspaceModule taskModule = _workspaceModuleEP.getModule(TasksWorkspaceModule.TASK_MODULE_ID);
531        ModifiableTraversableAmetysObject tasksRoot = taskModule.getTasksRoot(project, true);
532        return tasksRoot.getChildren()
533            .stream()
534            .filter(Task.class::isInstance)
535            .map(Task.class::cast)
536            .collect(Collectors.toList());
537    }
538    
539    /**
540     * Get the total number of tasks of the project
541     * @param project The project
542     * @return The number of tasks, or null if the module is not activated
543     */
544    public Long getTasksCount(Project project)
545    {
546        return Long.valueOf(getProjectTasks(project).size());
547    }
548    
549    /**
550     * Get project members
551     * @return the project members
552     * @throws AmetysRepositoryException if an error occurred
553     */
554    @Callable (rights = Callable.CHECKED_BY_IMPLEMENTATION)
555    public Map<String, Object> getProjectMembers() throws AmetysRepositoryException
556    {
557        String projectName = getProjectName();
558        String lang = getSitemapLanguage();
559        
560        return _projectMemberManager.getProjectMembers(projectName, lang, true);
561    }
562}