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