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