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