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 */ 016 017package org.ametys.plugins.workspaces.project.notification; 018 019import java.io.ByteArrayOutputStream; 020import java.io.IOException; 021import java.io.InputStream; 022import java.time.ZonedDateTime; 023import java.util.ArrayList; 024import java.util.Date; 025import java.util.HashMap; 026import java.util.HashSet; 027import java.util.List; 028import java.util.Map; 029import java.util.Set; 030import java.util.stream.Collectors; 031 032import org.apache.avalon.framework.service.ServiceException; 033import org.apache.avalon.framework.service.ServiceManager; 034import org.apache.cocoon.components.ContextHelper; 035import org.apache.cocoon.environment.Request; 036import org.apache.commons.lang3.StringUtils; 037import org.apache.excalibur.source.Source; 038import org.apache.excalibur.source.SourceResolver; 039import org.apache.excalibur.source.SourceUtil; 040import org.quartz.JobExecutionContext; 041 042import org.ametys.core.right.RightManager; 043import org.ametys.core.user.User; 044import org.ametys.core.util.DateUtils; 045import org.ametys.core.util.I18nUtils; 046import org.ametys.core.util.mail.SendMailHelper; 047import org.ametys.plugins.core.impl.schedule.AbstractStaticSchedulable; 048import org.ametys.plugins.explorer.resources.ModifiableResourceCollection; 049import org.ametys.plugins.repository.AmetysObjectIterable; 050import org.ametys.plugins.repository.events.DefaultEventType; 051import org.ametys.plugins.repository.events.EventTypeExtensionPoint; 052import org.ametys.plugins.repository.query.expression.AndExpression; 053import org.ametys.plugins.repository.query.expression.DateExpression; 054import org.ametys.plugins.repository.query.expression.Expression; 055import org.ametys.plugins.repository.query.expression.Expression.Operator; 056import org.ametys.plugins.repository.query.expression.StringExpression; 057import org.ametys.plugins.workspaces.events.WorkspacesEventType; 058import org.ametys.plugins.workspaces.members.ProjectMemberManager; 059import org.ametys.plugins.workspaces.members.ProjectMemberManager.ProjectMember; 060import org.ametys.plugins.workspaces.project.ProjectManager; 061import org.ametys.plugins.workspaces.project.modules.WorkspaceModule; 062import org.ametys.plugins.workspaces.project.notification.preferences.NotificationPreferencesHelper; 063import org.ametys.plugins.workspaces.project.notification.preferences.NotificationPreferencesHelper.Frequency; 064import org.ametys.plugins.workspaces.project.objects.Project; 065import org.ametys.runtime.i18n.I18nizable; 066import org.ametys.web.WebConstants; 067import org.ametys.web.renderingcontext.RenderingContext; 068import org.ametys.web.renderingcontext.RenderingContextHandler; 069import org.ametys.web.repository.site.Site; 070import org.ametys.web.repository.site.SiteManager; 071 072/** 073 * Abstract Class to send a mail with the summary of all notification for a time period 074 */ 075public abstract class AbstractSendNotificationSummarySchedulable extends AbstractStaticSchedulable 076{ 077 /** The project manager */ 078 protected ProjectManager _projectManager; 079 /** The notofication helper */ 080 protected NotificationPreferencesHelper _notificationPrefHelper; 081 082 private EventTypeExtensionPoint _eventTypeEP; 083 private I18nUtils _i18nUtils; 084 private ProjectMemberManager _projectMemberManager; 085 private RenderingContextHandler _renderingContextHandler; 086 private RightManager _rightManager; 087 private SiteManager _siteManager; 088 private SourceResolver _srcResolver; 089 090 091 @Override 092 public void service(ServiceManager manager) throws ServiceException 093 { 094 super.service(manager); 095 _eventTypeEP = (EventTypeExtensionPoint) manager.lookup(EventTypeExtensionPoint.ROLE); 096 _i18nUtils = (I18nUtils) manager.lookup(I18nUtils.ROLE); 097 _projectManager = (ProjectManager) manager.lookup(ProjectManager.ROLE); 098 _projectMemberManager = (ProjectMemberManager) manager.lookup(ProjectMemberManager.ROLE); 099 _renderingContextHandler = (RenderingContextHandler) manager.lookup(RenderingContextHandler.ROLE); 100 _rightManager = (RightManager) manager.lookup(RightManager.ROLE); 101 _siteManager = (SiteManager) manager.lookup(SiteManager.ROLE); 102 _srcResolver = (SourceResolver) manager.lookup(SourceResolver.ROLE); 103 _notificationPrefHelper = (NotificationPreferencesHelper) manager.lookup(NotificationPreferencesHelper.ROLE); 104 } 105 106 @Override 107 public void execute(JobExecutionContext context) throws Exception 108 { 109 List<Map<String, Object>> eventsInTimeFrame = _getAggregatedEventsInTimeFrame(); 110 if (eventsInTimeFrame.size() == 0) 111 { 112 return; 113 } 114 Set<User> userToNotify = _getUserToNotify(); 115 116 for (User user : userToNotify) 117 { 118 Map<String, Map<String, List<Map<String, Object>>>> userEventsInTimeFrame = _getEventsByProject(user, eventsInTimeFrame); 119 if (userEventsInTimeFrame.isEmpty()) 120 { 121 continue; 122 } 123 // here we select the first language we find in the events to notify 124 // this is not ideal at all. But as there is no localisation by user we don't really have a better choice 125 String lang = _projectManager.getProject(userEventsInTimeFrame.keySet().iterator().next()).getSites().iterator().next().getSitemaps().iterator().next().getName(); 126 127 String mailSubject = _i18nUtils.translate(getI18nSubject(), lang); 128 String mailBody = _getMailBody(user, userEventsInTimeFrame, lang); 129 130 SendMailHelper.newMail() 131 .withRecipient(user.getEmail()) 132 .withSubject(mailSubject) 133 .withHTMLBody(mailBody) 134 .withInlineCSS(false) 135 .withAsync(true) 136 .sendMail(); 137 } 138 } 139 140 /** 141 * Get all event that happened in the time frame. 142 * Event are sorted by project 143 * @return the events, sorted by project 144 */ 145 private List<Map<String, Object>> _getAggregatedEventsInTimeFrame() 146 { 147 Expression projectExpr = new StringExpression("projectName", Operator.NE, ""); 148 149 ZonedDateTime frameLimit = getTimeFrameLimit(); 150 Expression dateExpr = new DateExpression("date", Operator.GE, DateUtils.asDate(frameLimit)); 151 152 Expression finalExpr = new AndExpression(projectExpr, dateExpr); 153 154 List<Map<String, Object>> events = _eventTypeEP.getEvents(finalExpr); 155 156 return _aggregateEvents(events); 157 } 158 159 private List<Map<String, Object>> _aggregateEvents(List<Map<String, Object>> events) 160 { 161 List<Map<String, Object>> aggregatedEvents = new ArrayList<>(); 162 for (Map<String, Object> event : events) 163 { 164 // We retrieve all the event that are similar 165 List<Map<String, Object>> sameEvents = aggregatedEvents.stream().filter(e -> _isAlreadyPresent(event, e)).collect(Collectors.toList()); 166 if (sameEvents.size() == 0) 167 { 168 // None are find, we just add the new event 169 aggregatedEvents.add(event); 170 } 171 else 172 { 173 // We removed all the similar events 174 aggregatedEvents.removeAll(sameEvents); 175 // Find the one we want to store 176 Map<String, Object> finalEvent = event; 177 ZonedDateTime finalEventDate = DateUtils.asZonedDateTime((Date) finalEvent.get(DefaultEventType.DATE)); 178 boolean multipleAuthor = false; 179 int nbOfOccurrence = 1; 180 // There should never be more than one at a time still 181 for (Map<String, Object> sameEvent : sameEvents) 182 { 183 if (sameEvent.get("nbOfOccurence") != null) 184 { 185 nbOfOccurrence += (int) sameEvent.get("nbOfOccurrence"); 186 } 187 else 188 { 189 nbOfOccurrence++; 190 } 191 // If we find similar event with different author we will have to change the author 192 if (!multipleAuthor && !finalEvent.get(DefaultEventType.AUTHOR).equals(sameEvent.get(DefaultEventType.AUTHOR))) 193 { 194 multipleAuthor = true; 195 } 196 ZonedDateTime eventDate = DateUtils.asZonedDateTime((Date) sameEvent.get(DefaultEventType.DATE)); 197 if (eventDate.isAfter(finalEventDate)) 198 { 199 finalEvent = sameEvent; 200 finalEventDate = eventDate; 201 } 202 } 203 if (multipleAuthor) 204 { 205 finalEvent.remove(DefaultEventType.AUTHOR); 206 } 207 finalEvent.put("nbOfOccurrence", nbOfOccurrence); 208 aggregatedEvents.add(finalEvent); 209 } 210 } 211 return aggregatedEvents; 212 } 213 214 private boolean _isAlreadyPresent(Map<String, Object> event, Map<String, Object> event2) 215 { 216 if (!event.get(DefaultEventType.TYPE).equals(event2.get(DefaultEventType.TYPE))) 217 { 218 return false; 219 } 220 221 String type = (String) event.get(DefaultEventType.TYPE); 222 switch (StringUtils.substringBefore(type, ".")) 223 { 224 case "calendar": 225 return _calendarEventAlreadyPresent(event, event2); 226 case "member": 227 return _memberEventAlreadyPresent(event, event2); 228 case "task": 229 return _taskEventAlreadyPresent(event, event2); 230 case "thread": 231 return _threadEventAlreadyPresent(event, event2); 232 case "resource": 233 return _resourceEventAlreadyPresent(event, event2); 234 case "wallcontent": 235 return _wallContentEventAlreadyPresent(event, event2); 236 default: 237 return false; 238 } 239 } 240 241 private boolean _wallContentEventAlreadyPresent(Map<String, Object> event, Map<String, Object> event2) 242 { 243 if (event.get("contentId") != null && event2.get("contentId") != null) 244 { 245 return event.get("contentId").equals(event2.get("contentId")); 246 } 247 return false; 248 } 249 250 private boolean _resourceEventAlreadyPresent(Map<String, Object> event, Map<String, Object> event2) 251 { 252 if (event.get("file") != null && event2.get("file") != null) 253 { 254 @SuppressWarnings("unchecked") 255 Map<String, Object> file = (Map<String, Object>) event.get("file"); 256 @SuppressWarnings("unchecked") 257 Map<String, Object> file2 = (Map<String, Object>) event2.get("file"); 258 if (file.get("id") != null && file2.get("id") != null) 259 { 260 return file.get("id").equals(file2.get("id")); 261 } 262 } 263 return false; 264 } 265 266 private boolean _threadEventAlreadyPresent(Map<String, Object> event, Map<String, Object> event2) 267 { 268 if (StringUtils.startsWith((String) event.get(DefaultEventType.TYPE), "thread.post")) 269 { 270 return false; 271 } 272 else if (event.get("threadId") != null && event2.get("threadId") != null) 273 { 274 return event.get("threadId").equals(event2.get("threadId")); 275 } 276 return false; 277 } 278 279 private boolean _taskEventAlreadyPresent(Map<String, Object> event, Map<String, Object> event2) 280 { 281 if (event.get("taskId") != null && event2.get("taskId") != null) 282 { 283 return event.get("taskId").equals(event2.get("taskId")); 284 } 285 return false; 286 } 287 288 private boolean _memberEventAlreadyPresent(Map<String, Object> event, Map<String, Object> event2) 289 { 290 if (event.get("identity") != null && event2.get("identity") != null) 291 { 292 return event.get("identity").equals(event2.get("identity")); 293 } 294 return false; 295 } 296 297 private boolean _calendarEventAlreadyPresent(Map<String, Object> event, Map<String, Object> event2) 298 { 299 if (event.get("eventId") != null && event2.get("eventId") != null) 300 { 301 return event.get("eventId").equals(event2.get("eventId")); 302 } 303 return false; 304 } 305 306 /** 307 * Get the earliest event's date we want to retrieve 308 * @return the date after which we want to retrieve event 309 */ 310 protected abstract ZonedDateTime getTimeFrameLimit(); 311 312 /** 313 * Get all user who have this time frame set in there userPrefs 314 * @return the list of user to notify 315 */ 316 private Set<User> _getUserToNotify() 317 { 318 Set<User> users = new HashSet<>(); 319 AmetysObjectIterable<Project> projects = _projectManager.getProjects(); 320 321 // Get all members of projects 322 for (Project project : projects) 323 { 324 Set<ProjectMember> projectMembers = _projectMemberManager.getProjectMembers(project, true); 325 Set<User> projectUsers = projectMembers.stream().map(ProjectMember::getUser).collect(Collectors.toSet()); 326 users.addAll(projectUsers); 327 } 328 329 return users.stream() 330 // if the user has no mail then no need to compute notification. 331 .filter(user -> StringUtils.isNotEmpty(user.getEmail())) 332 // ensure that the user has this frequency somewhere in his preference 333 .filter(user -> _notificationPrefHelper.hasFrequencyInPreferences(user, getFrequency())) 334 .collect(Collectors.toSet()); 335 } 336 337 /** 338 * Get the notification frequency 339 * @return the frequency 340 */ 341 protected abstract Frequency getFrequency(); 342 343 private Map<String, Map<String, List<Map<String, Object>>>> _getEventsByProject(User user, List<Map<String, Object>> eventsInTimeFrame) 344 { 345 Set<String> projectNames = _notificationPrefHelper.getUserProjectsWithFrequency(user, getFrequency()); 346 347 Map<String, Map<String, List<Map<String, Object>>>> userEvents = new HashMap<>(); 348 for (String projectName : projectNames) 349 { 350 Set<String> allowedType = _getAllowedEventTypes(user, projectName); 351 Map<String, List<Map<String, Object>>> userEventsByType = _getUserProjectEvents(projectName, allowedType, eventsInTimeFrame); 352 if (!userEventsByType.isEmpty()) 353 { 354 userEvents.put(projectName, userEventsByType); 355 } 356 } 357 return userEvents; 358 } 359 360 private Set<String> _getAllowedEventTypes(User user, String projectName) 361 { 362 Project project = _projectManager.getProject(projectName); 363 if (project == null) 364 { 365 // project does not exist anymore, ignore all events 366 return Set.of(); 367 } 368 369 Set<String> allowedTypes = new HashSet<>(); 370 for (WorkspaceModule module : _projectManager.getModules(project)) 371 { 372 ModifiableResourceCollection moduleRoot = module.getModuleRoot(project, false); 373 if (moduleRoot != null && _rightManager.hasReadAccess(user.getIdentity(), moduleRoot)) 374 { 375 allowedTypes.addAll(module.getAllowedEventTypes()); 376 } 377 } 378 379 return allowedTypes; 380 } 381 382 private Map<String, List<Map<String, Object>>> _getUserProjectEvents(String projectName, Set<String> allowedType, List<Map<String, Object>> eventsInTimeFrame) 383 { 384 Map<String, List<Map<String, Object>>> userEventsByType = new HashMap<>(); 385 for (Map<String, Object> eventJSON : eventsInTimeFrame) 386 { 387 String eventType = (String) eventJSON.get(DefaultEventType.TYPE); 388 if (allowedType.contains(eventType) && eventJSON.get(WorkspacesEventType.PROJECT_NAME).equals(projectName)) 389 { 390 if (!userEventsByType.containsKey(eventType)) 391 { 392 userEventsByType.put(eventType, new ArrayList<>()); 393 } 394 // TODO we should implement here if we send all notification or only those concerning the user 395 userEventsByType.get(eventType).add(eventJSON); 396 } 397 } 398 return userEventsByType; 399 } 400 401 /** 402 * Get the subject of the mail 403 * @return the subject of the mail 404 */ 405 protected abstract I18nizable getI18nSubject(); 406 407 private String _getMailBody(User user, Map<String, Map<String, List<Map<String, Object>>>> events, String lang) 408 { 409 410 Source source = null; 411 RenderingContext currentContext = _renderingContextHandler.getRenderingContext(); 412 Request request = ContextHelper.getRequest(_context); 413 414 try 415 { 416 // Force rendering context.FRONT to resolve URI 417 _renderingContextHandler.setRenderingContext(RenderingContext.FRONT); 418 419 request.setAttribute("forceAbsoluteUrl", true); 420 request.setAttribute("forceBase64Encoding", true); // for image using uri resolver 421 422 request.setAttribute("lang", lang); 423 String siteName = _projectManager.getCatalogSiteName(); 424 Site site = _siteManager.getSite(siteName); 425 426 request.setAttribute(WebConstants.REQUEST_ATTR_SITE, site); 427 request.setAttribute(WebConstants.REQUEST_ATTR_SITE_NAME, siteName); 428 request.setAttribute(WebConstants.REQUEST_ATTR_SKIN_ID, site.getSkinId()); 429 430 source = _srcResolver.resolveURI("cocoon://_plugins/workspaces/notification-mail-summary.html", 431 null, 432 Map.of("frequency", getFrequency().name(), "events", events, "user", user)); 433 434 try (InputStream is = source.getInputStream()) 435 { 436 ByteArrayOutputStream bos = new ByteArrayOutputStream(); 437 SourceUtil.copy(is, bos); 438 439 return bos.toString("UTF-8"); 440 } 441 } 442 catch (IOException e) 443 { 444 throw new RuntimeException("Failed to get mail body for workspaces notifications", e); 445 } 446 finally 447 { 448 request.removeAttribute("forceBase64Encoding"); 449 _renderingContextHandler.setRenderingContext(currentContext); 450 451 if (source != null) 452 { 453 _srcResolver.release(source); 454 } 455 } 456 } 457}