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