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