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