001/* 002 * Copyright 2019 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.odf.course; 017 018import java.io.IOException; 019import java.time.LocalDate; 020import java.time.ZonedDateTime; 021import java.util.ArrayList; 022import java.util.Collections; 023import java.util.HashMap; 024import java.util.HashSet; 025import java.util.List; 026import java.util.Map; 027import java.util.Map.Entry; 028import java.util.Objects; 029import java.util.Set; 030import java.util.stream.Collectors; 031import java.util.stream.Stream; 032 033import org.apache.avalon.framework.component.Component; 034import org.apache.avalon.framework.service.ServiceException; 035import org.apache.avalon.framework.service.ServiceManager; 036import org.apache.avalon.framework.service.Serviceable; 037import org.apache.commons.lang3.StringUtils; 038import org.apache.commons.lang3.tuple.Pair; 039 040import org.ametys.cms.ObservationConstants; 041import org.ametys.cms.content.ContentHelper; 042import org.ametys.cms.data.ContentValue; 043import org.ametys.cms.repository.Content; 044import org.ametys.cms.repository.ModifiableContent; 045import org.ametys.core.observation.Event; 046import org.ametys.core.observation.ObservationManager; 047import org.ametys.core.right.RightManager; 048import org.ametys.core.right.RightManager.RightResult; 049import org.ametys.core.ui.mail.StandardMailBodyHelper; 050import org.ametys.core.ui.mail.StandardMailBodyHelper.MailBodyBuilder; 051import org.ametys.core.ui.mail.StandardMailBodyHelper.MailBodyBuilder.UserInput; 052import org.ametys.core.user.CurrentUserProvider; 053import org.ametys.core.user.User; 054import org.ametys.core.user.UserIdentity; 055import org.ametys.core.user.UserManager; 056import org.ametys.core.util.I18nUtils; 057import org.ametys.core.util.language.UserLanguagesManager; 058import org.ametys.core.util.mail.SendMailHelper; 059import org.ametys.plugins.repository.AmetysObjectResolver; 060import org.ametys.plugins.repository.data.holder.group.ModifiableModelAwareComposite; 061import org.ametys.runtime.config.Config; 062import org.ametys.runtime.i18n.I18nizableText; 063import org.ametys.runtime.model.ModelItem; 064import org.ametys.runtime.plugin.component.AbstractLogEnabled; 065 066import jakarta.mail.MessagingException; 067 068/** 069 * Helper for shareable course status 070 */ 071public class ShareableCourseStatusHelper extends AbstractLogEnabled implements Component, Serviceable 072{ 073 /** The component role. */ 074 public static final String ROLE = ShareableCourseStatusHelper.class.getName(); 075 076 /** The metadata name for the date of the "proposed" state */ 077 private static final String __PROPOSED_DATE_METADATA = "proposed_date"; 078 079 /** The metadata name for the author of the "proposed" state */ 080 private static final String __PROPOSED_AUTHOR_METADATA = "proposed_author"; 081 082 /** The metadata name for the date of the "validated" state */ 083 private static final String __VALIDATED_DATE_METADATA = "validated_date"; 084 085 /** The metadata name for the author of the "validated" state */ 086 private static final String __VALIDATED_AUTHOR_METADATA = "validated_author"; 087 088 /** The metadata name for the date of the "refused" state */ 089 private static final String __REFUSED_DATE_METADATA = "refused_date"; 090 091 /** The metadata name for the author of the "refused" state */ 092 private static final String __REFUSED_AUTHOR_METADATA = "refused_author"; 093 094 /** The metadata name for the comment of the "refused" state */ 095 private static final String __REFUSED_COMMENT_METADATA = "refused_comment"; 096 097 /** The notification right id */ 098 private static final String __NOTIFICATION_RIGHT_ID = "ODF_Rights_Shareable_Course_Receive_Notification"; 099 100 /** The propose right id */ 101 private static final String __PROPOSE_RIGHT_ID = "ODF_Rights_Shareable_Course_Propose"; 102 103 /** The validate right id */ 104 private static final String __VALIDATE_RIGHT_ID = "ODF_Rights_Shareable_Course_Validate"; 105 106 /** The refusal right id */ 107 private static final String __REFUSE_RIGHT_ID = "ODF_Rights_Shareable_Course_Refuse"; 108 109 /** The current user provider */ 110 protected CurrentUserProvider _currentUserProvider; 111 112 /** The observation manager */ 113 protected ObservationManager _observationManager; 114 115 /** The right manager */ 116 protected RightManager _rightManager; 117 118 /** The user manager */ 119 protected UserManager _userManager; 120 121 /** The i18n utils */ 122 protected I18nUtils _i18nUtils; 123 124 /** The content helper */ 125 protected ContentHelper _contentHelper; 126 127 /** The ametys object resolver */ 128 protected AmetysObjectResolver _resolver; 129 130 /** The user languages manager */ 131 protected UserLanguagesManager _userLanguagesManager; 132 133 /** 134 * Enumeration for the shareable course status 135 */ 136 public enum ShareableStatus 137 { 138 /** Aucun */ 139 NONE, 140 /** Proposé */ 141 PROPOSED, 142 /** Validé */ 143 VALIDATED, 144 /** Refusé */ 145 REFUSED 146 } 147 148 @Override 149 public void service(ServiceManager manager) throws ServiceException 150 { 151 _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE); 152 _observationManager = (ObservationManager) manager.lookup(ObservationManager.ROLE); 153 _rightManager = (RightManager) manager.lookup(RightManager.ROLE); 154 _userManager = (UserManager) manager.lookup(UserManager.ROLE); 155 _i18nUtils = (I18nUtils) manager.lookup(I18nUtils.ROLE); 156 _contentHelper = (ContentHelper) manager.lookup(ContentHelper.ROLE); 157 _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE); 158 _userLanguagesManager = (UserLanguagesManager) manager.lookup(UserLanguagesManager.ROLE); 159 } 160 161 /** 162 * Get the shareable status of the course 163 * @param content the content 164 * @return the shareable course status 165 */ 166 public ShareableStatus getShareableStatus(Content content) 167 { 168 String metadataPath = ShareableCourseConstants.SHAREABLE_COURSE_COMPOSITE_METADATA + ModelItem.ITEM_PATH_SEPARATOR + ShareableCourseConstants.SHAREABLE_COURSE_STATUS_METADATA; 169 String status = content.getValue(metadataPath, false, ShareableStatus.NONE.name()); 170 return ShareableStatus.valueOf(status.toUpperCase()); 171 } 172 173 /** 174 * Set the workflow state attribute (date, login) to the content 175 * @param content the content 176 * @param validationDate the validation date 177 * @param user the user 178 * @param status the shareable course status 179 * @param comment the comment. Can be null 180 * @param ignoreRights true to ignore user rights 181 */ 182 public void setWorkflowStateAttribute(ModifiableContent content, LocalDate validationDate, UserIdentity user, ShareableStatus status, String comment, boolean ignoreRights) 183 { 184 boolean hasChanges = false; 185 switch (status) 186 { 187 case NONE : 188 if (ignoreRights || _rightManager.hasRight(user, __REFUSE_RIGHT_ID, content).equals(RightResult.RIGHT_ALLOW)) 189 { 190 UserIdentity proposedStateAuthor = getProposedStateAuthor(content); 191 hasChanges = setRefusedStateAttribute(content, validationDate, user, comment); 192 193 // Send mail to users who propose the course as shareable 194 _sendNotificationMail( 195 content, 196 proposedStateAuthor != null ? Collections.singleton(proposedStateAuthor) : new HashSet<>(), 197 comment, 198 "PLUGINS_ODF_SHAREABLE_COURSE_WORKFLOW_MAIL_SUBJECT_ACTION_REFUSE", 199 "PLUGINS_ODF_SHAREABLE_COURSE_WORKFLOW_MAIL_BODY_ACTION_REFUSE" 200 ); 201 } 202 break; 203 case PROPOSED : 204 if (ignoreRights || _rightManager.hasRight(user, __PROPOSE_RIGHT_ID, content).equals(RightResult.RIGHT_ALLOW)) 205 { 206 hasChanges = setProposedStateAttribute(content, validationDate, user); 207 208 // Send mail to users who have the right "ODF_Rights_Shareable_Course_Receive_Notification" 209 Set<UserIdentity> users = _rightManager.getAllowedUsers(__NOTIFICATION_RIGHT_ID, content).resolveAllowedUsers(Config.getInstance().getValue("runtime.mail.massive.sending")); 210 _sendNotificationMail( 211 content, 212 users, 213 comment, 214 "PLUGINS_ODF_SHAREABLE_COURSE_WORKFLOW_MAIL_SUBJECT_ACTION_PROPOSE", 215 "PLUGINS_ODF_SHAREABLE_COURSE_WORKFLOW_MAIL_BODY_ACTION_PROPOSE" 216 ); 217 } 218 break; 219 case VALIDATED : 220 if (ignoreRights || _rightManager.hasRight(user, __VALIDATE_RIGHT_ID, content).equals(RightResult.RIGHT_ALLOW)) 221 { 222 hasChanges = setValidatedStateAttribute(content, validationDate, user); 223 224 // Send mail to users who propose the course as shareable 225 UserIdentity proposedStateAuthor = getProposedStateAuthor(content); 226 if (proposedStateAuthor != null) 227 { 228 _sendNotificationMail( 229 content, 230 Collections.singleton(proposedStateAuthor), 231 comment, 232 "PLUGINS_ODF_SHAREABLE_COURSE_WORKFLOW_MAIL_SUBJECT_ACTION_VALIDATE", 233 "PLUGINS_ODF_SHAREABLE_COURSE_WORKFLOW_MAIL_BODY_ACTION_VALIDATE" 234 ); 235 } 236 } 237 break; 238 default : 239 getLogger().error("{} is an unknown shareable course status", status); 240 } 241 242 if (hasChanges) 243 { 244 _notifyShareableCourseWorkflowModification(content); 245 } 246 } 247 248 /** 249 * Remove the shareable course workflow metadata. 250 * @param content The content to clean 251 * @return <code>true</code> if the content has changed 252 */ 253 public boolean removeShareableWorkflow(ModifiableContent content) 254 { 255 content.removeValue(ShareableCourseConstants.SHAREABLE_COURSE_COMPOSITE_METADATA); 256 257 if (content.needsSave()) 258 { 259 content.saveChanges(); 260 return true; 261 } 262 263 return false; 264 } 265 266 /** 267 * Set the attribute for 'proposed' state 268 * @param content the content 269 * @param proposeDate the proposed date 270 * @param user the user 271 * @return <code>true</code> if the content has changed. 272 */ 273 public boolean setProposedStateAttribute(ModifiableContent content, LocalDate proposeDate, UserIdentity user) 274 { 275 ModifiableModelAwareComposite composite = content.getComposite(ShareableCourseConstants.SHAREABLE_COURSE_COMPOSITE_METADATA, true); 276 277 composite.setValue(__PROPOSED_DATE_METADATA, proposeDate); 278 composite.setValue(__PROPOSED_AUTHOR_METADATA, user); 279 280 composite.setValue(ShareableCourseConstants.SHAREABLE_COURSE_STATUS_METADATA, ShareableStatus.PROPOSED.name()); 281 282 if (content.needsSave()) 283 { 284 content.saveChanges(); 285 return true; 286 } 287 288 return false; 289 } 290 291 /** 292 * Set the attribute for 'validated' state 293 * @param content the content 294 * @param validationDate the validation date 295 * @param user the login 296 * @return <code>true</code> if the content has changed. 297 */ 298 public boolean setValidatedStateAttribute(ModifiableContent content, LocalDate validationDate, UserIdentity user) 299 { 300 ModifiableModelAwareComposite composite = content.getComposite(ShareableCourseConstants.SHAREABLE_COURSE_COMPOSITE_METADATA, true); 301 302 composite.setValue(__VALIDATED_DATE_METADATA, validationDate); 303 composite.setValue(__VALIDATED_AUTHOR_METADATA, user); 304 305 composite.setValue(ShareableCourseConstants.SHAREABLE_COURSE_STATUS_METADATA, ShareableStatus.VALIDATED.name()); 306 307 if (content.needsSave()) 308 { 309 content.saveChanges(); 310 return true; 311 } 312 313 return false; 314 } 315 316 /** 317 * Set the attribute for 'validated' state 318 * @param content the content 319 * @param validationDate the validation date 320 * @param user the login 321 * @param comment the comment. Can be null 322 * @return <code>true</code> if the content has changed. 323 */ 324 public boolean setRefusedStateAttribute(ModifiableContent content, LocalDate validationDate, UserIdentity user, String comment) 325 { 326 ModifiableModelAwareComposite composite = content.getComposite(ShareableCourseConstants.SHAREABLE_COURSE_COMPOSITE_METADATA, true); 327 328 composite.setValue(__REFUSED_DATE_METADATA, validationDate); 329 composite.setValue(__REFUSED_AUTHOR_METADATA, user); 330 if (StringUtils.isNotBlank(comment)) 331 { 332 composite.setValue(__REFUSED_COMMENT_METADATA, comment); 333 } 334 335 composite.setValue(ShareableCourseConstants.SHAREABLE_COURSE_STATUS_METADATA, ShareableStatus.REFUSED.name()); 336 337 if (content.needsSave()) 338 { 339 content.saveChanges(); 340 return true; 341 } 342 343 return false; 344 } 345 346 /** 347 * Get 'proposed' state author 348 * @param content the content 349 * @return the 'proposed' state author 350 */ 351 public UserIdentity getProposedStateAuthor(Content content) 352 { 353 return content.getValue(ShareableCourseConstants.SHAREABLE_COURSE_COMPOSITE_METADATA + ModelItem.ITEM_PATH_SEPARATOR + __PROPOSED_AUTHOR_METADATA, false, null); 354 } 355 356 /** 357 * Send a notification with the content modified event. 358 * @param content The content to notify on 359 */ 360 protected void _notifyShareableCourseWorkflowModification(Content content) 361 { 362 Map<String, Object> eventParams = new HashMap<>(); 363 eventParams.put(ObservationConstants.ARGS_CONTENT, content); 364 eventParams.put(ObservationConstants.ARGS_CONTENT_ID, content.getId()); 365 _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_MODIFIED, _currentUserProvider.getUser(), eventParams)); 366 } 367 368 /** 369 * Send mail to users 370 * @param content the course modified 371 * @param usersNotified the list of user to notify 372 * @param comment the comment. Can be null 373 * @param mailSubjectKey the mail subject i18n key 374 * @param mailBodyKey the mail body i18n key 375 */ 376 protected void _sendNotificationMail(Content content, Set<UserIdentity> usersNotified, String comment, String mailSubjectKey, String mailBodyKey) 377 { 378 UserIdentity currentUser = _currentUserProvider.getUser(); 379 380 String defaultLanguage = _userLanguagesManager.getDefaultLanguage(); 381 382 Map<String, List<String>> recipientsByLanguage = usersNotified.stream() 383 .map(_userManager::getUser) 384 .filter(Objects::nonNull) 385 .map(user -> Pair.of(user, user.getEmail())) 386 .filter(p -> StringUtils.isNotEmpty(p.getRight())) 387 .collect(Collectors.groupingBy( 388 p -> { 389 return StringUtils.defaultIfBlank(p.getLeft().getLanguage(), defaultLanguage); 390 }, 391 Collectors.mapping( 392 Pair::getRight, 393 Collectors.toList() 394 ) 395 ) 396 ); 397 398 for (String language : recipientsByLanguage.keySet()) 399 { 400 List<String> recipients = recipientsByLanguage.get(language); 401 try 402 { 403 String mailSubject = _getMailSubject(mailSubjectKey, content, language); 404 String htmlBody = _getMailBody(mailSubjectKey, mailBodyKey, _userManager.getUser(currentUser), content, comment, language); 405 406 SendMailHelper.newMail() 407 .withSubject(mailSubject) 408 .withHTMLBody(htmlBody) 409 .withRecipients(recipients) 410 .withAsync(true) 411 .sendMail(); 412 } 413 catch (MessagingException | IOException e) 414 { 415 getLogger().warn("Could not send a notification mail to " + recipients, e); 416 } 417 } 418 } 419 420 /** 421 * Get the subject of mail 422 * @param subjectI18nKey the i18n key to use for subject 423 * @param content the content 424 * @param language The language to use 425 * @return the subject 426 */ 427 protected String _getMailSubject (String subjectI18nKey, Content content, String language) 428 { 429 I18nizableText subjectKey = new I18nizableText("plugin.odf", subjectI18nKey, _getSubjectI18nParams(content)); 430 return _i18nUtils.translate(subjectKey, language); 431 } 432 433 /** 434 * Get the i18n parameters of mail subject 435 * @param content the content 436 * @return the i18n parameters 437 */ 438 protected List<String> _getSubjectI18nParams (Content content) 439 { 440 List<String> params = new ArrayList<>(); 441 params.add(_contentHelper.getTitle(content)); 442 return params; 443 } 444 445 /** 446 * Get the text body of mail 447 * @param subjectI18nKey the i18n key to use for body 448 * @param bodyI18nKey the i18n key to use for body 449 * @param user the caller 450 * @param content the content 451 * @param comment the comment. Can be null 452 * @param language The language to use 453 * @return the text body 454 * @throws IOException if failed to build HTML bodu 455 */ 456 protected String _getMailBody (String subjectI18nKey, String bodyI18nKey, User user, Content content, String comment, String language) throws IOException 457 { 458 String contentUri = _getContentUri(content); 459 460 MailBodyBuilder bodyBuilder = StandardMailBodyHelper.newHTMLBody() 461 .withLanguage(language) 462 .withTitle(_getMailSubject(subjectI18nKey, content, language)) 463 .addMessage(new I18nizableText("plugin.odf", bodyI18nKey, _getBodyI18nParams(user, content))) 464 .addMessage(new I18nizableText("plugin.odf", "PLUGINS_ODF_SHAREABLE_COURSE_WORKFLOW_MAIL_BODY_LINK", List.of(contentUri))) 465 .withLink(contentUri, new I18nizableText("plugin.odf", "PLUGINS_ODF_SHAREABLE_COURSE_WORKFLOW_MAIL_BODY_LINK_TITLE")) 466 .withDetails(new I18nizableText("plugin.odf", "PLUGINS_ODF_SHAREABLE_COURSE_WORKFLOW_MAIL_BODY_FILTERS_MSG"), _getFiltersText(content, language), false); 467 468 469 if (StringUtils.isNotEmpty(comment)) 470 { 471 bodyBuilder.withUserInputs(List.of(new UserInput(user, ZonedDateTime.now(), comment)), new I18nizableText("plugin.cms", "WORKFLOW_MAIL_BODY_USER_COMMENT")); 472 } 473 474 return bodyBuilder.build(); 475 } 476 477 /** 478 * Get the i18n parameters of mail body text 479 * @param user the caller 480 * @param content the content 481 * @return the i18n parameters 482 */ 483 protected List<String> _getBodyI18nParams (User user, Content content) 484 { 485 List<String> params = new ArrayList<>(); 486 487 params.add(user.getFullName()); // {0} 488 params.add(content.getTitle()); // {1} 489 params.add(_getContentUri(content)); // {2} 490 491 return params; 492 } 493 494 /** 495 * Get the i18n parameters of mail footer text 496 * @param user the caller 497 * @param content the content 498 * @return the i18n parameters 499 */ 500 protected List<String> _getFooterI18nParams (User user, Content content) 501 { 502 List<String> params = new ArrayList<>(); 503 504 params.add(_getContentUri(content)); // {0} 505 506 return params; 507 } 508 509 /** 510 * Get the list of filter as text for the mail body 511 * @param content the content 512 * @param language The language to use 513 * @return the list of filter as text 514 */ 515 protected String _getFiltersText(Content content, String language) 516 { 517 Map<I18nizableText, List<String>> filters = new HashMap<>(); 518 519 List<String> filteredPrograms = _getFilterList(content, ShareableCourseConstants.PROGRAMS_FIELD_ATTRIBUTE_NAME); 520 if (!filteredPrograms.isEmpty()) 521 { 522 filters.put(new I18nizableText("plugin.odf", "PLUGINS_ODF_SHAREABLE_COURSE_WORKFLOW_MAIL_BODY_FILTER_PROGRAMS"), filteredPrograms); 523 } 524 525 List<String> filteredDegrees = _getFilterList(content, ShareableCourseConstants.DEGREES_FIELD_ATTRIBUTE_NAME); 526 if (!filteredDegrees.isEmpty()) 527 { 528 filters.put(new I18nizableText("plugin.odf", "PLUGINS_ODF_SHAREABLE_COURSE_WORKFLOW_MAIL_BODY_FILTER_DEGREES"), filteredDegrees); 529 } 530 531 List<String> filteredOUs = _getFilterList(content, ShareableCourseConstants.ORGUNITS_FIELD_ATTRIBUTE_NAME); 532 if (!filteredOUs.isEmpty()) 533 { 534 filters.put(new I18nizableText("plugin.odf", "PLUGINS_ODF_SHAREABLE_COURSE_WORKFLOW_MAIL_BODY_FILTER_ORGUNITS"), filteredOUs); 535 } 536 537 List<String> filteredPeriods = _getFilterList(content, ShareableCourseConstants.PERIODS_FIELD_ATTRIBUTE_NAME); 538 if (!filteredPeriods.isEmpty()) 539 { 540 filters.put(new I18nizableText("plugin.odf", "PLUGINS_ODF_SHAREABLE_COURSE_WORKFLOW_MAIL_BODY_FILTER_PERIODS"), filteredPeriods); 541 } 542 543 if (filters.isEmpty()) 544 { 545 return _i18nUtils.translate(new I18nizableText("plugin.odf", "PLUGINS_ODF_SHAREABLE_COURSE_WORKFLOW_MAIL_BODY_NO_FILTERS_MSG"), language); 546 } 547 else 548 { 549 StringBuilder sb = new StringBuilder(); 550 sb.append("<ul>"); 551 552 for (Entry<I18nizableText, List<String>> filter: filters.entrySet()) 553 { 554 sb.append("<li>"); 555 sb.append(_i18nUtils.translate(filter.getKey(), language)); 556 sb.append("<ul>"); 557 filter.getValue().stream().forEach(e -> sb.append("<li>").append(e).append("</li>")); 558 sb.append("</ul>"); 559 sb.append("</li>"); 560 } 561 sb.append("</ul>"); 562 return sb.toString(); 563 } 564 } 565 566 private List<String> _getFilterList(Content content, String attribute) 567 { 568 ContentValue[] values = content.getValue(attribute, false, new ContentValue[0]); 569 570 return Stream.of(values) 571 .map(ContentValue::getContent) 572 .map(Content::getTitle) 573 .collect(Collectors.toList()); 574 } 575 576 /** 577 * Get the content uri 578 * @param content the content 579 * @return the content uri 580 */ 581 protected String _getContentUri(Content content) 582 { 583 return _contentHelper.getContentBOUrl(content, Map.of()); 584 } 585}