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}