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