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