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