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}