001/*
002 *  Copyright 2020 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.schedulable;
017
018import java.io.File;
019import java.io.FileOutputStream;
020import java.io.IOException;
021import java.io.InputStream;
022import java.io.OutputStream;
023import java.time.ZonedDateTime;
024import java.time.format.DateTimeFormatter;
025import java.util.ArrayList;
026import java.util.HashMap;
027import java.util.List;
028import java.util.Map;
029import java.util.Map.Entry;
030import java.util.Optional;
031import java.util.stream.Collectors;
032import java.util.stream.Stream;
033
034import org.apache.avalon.framework.service.ServiceException;
035import org.apache.avalon.framework.service.ServiceManager;
036import org.apache.cocoon.components.ContextHelper;
037import org.apache.cocoon.components.source.impl.SitemapSource;
038import org.apache.cocoon.environment.Request;
039import org.apache.commons.io.FileUtils;
040import org.apache.commons.lang.StringUtils;
041import org.apache.commons.lang3.exception.ExceptionUtils;
042import org.apache.excalibur.source.SourceResolver;
043import org.apache.excalibur.source.SourceUtil;
044import org.quartz.JobDataMap;
045import org.quartz.JobDetail;
046import org.quartz.JobExecutionContext;
047
048import org.ametys.cms.repository.Content;
049import org.ametys.cms.schedule.AbstractSendingMailSchedulable;
050import org.ametys.cms.workflow.ContentWorkflowHelper;
051import org.ametys.core.schedule.Schedulable;
052import org.ametys.core.schedule.progression.ContainerProgressionTracker;
053import org.ametys.core.ui.mail.StandardMailBodyHelper;
054import org.ametys.core.ui.mail.StandardMailBodyHelper.MailBodyBuilder;
055import org.ametys.odf.ProgramItem;
056import org.ametys.odf.schedulable.EducationalBookletSchedulable.EducationalBookletReport.ReportStatus;
057import org.ametys.plugins.core.schedule.Scheduler;
058import org.ametys.plugins.repository.AmetysObjectResolver;
059import org.ametys.runtime.config.Config;
060import org.ametys.runtime.i18n.I18nizableText;
061import org.ametys.runtime.i18n.I18nizableTextParameter;
062import org.ametys.runtime.util.AmetysHomeHelper;
063
064/**
065 * {@link Schedulable} for educational booklet.
066 */
067public class EducationalBookletSchedulable extends AbstractSendingMailSchedulable
068{
069    /** The directory under ametys home data directory for educational booklet */
070    public static final String EDUCATIONAL_BOOKLET_DIR_NAME = "odf/booklet";
071    
072    /** Scheduler parameter name of including of subprograms */
073    public static final String PARAM_INCLUDE_SUBPROGRAMS = "includeSubPrograms";
074    
075    /** Scheduler parameter name of including of subprograms */
076    public static final String PARAM_PROGRAM_ITEM_ID = "programItemId";
077    
078    /** Map key where the report is stored */
079    protected static final String _EDUCATIONAL_BOOKLET_REPORT = "educationalBookletReport";
080
081    /** The avalon source resolver. */
082    protected SourceResolver _sourceResolver;
083    
084    /** The ametys object resolver */
085    protected AmetysObjectResolver _resolver;
086    
087    /** The content workflow helper */
088    protected ContentWorkflowHelper _contentWorkflowHelper;
089    
090    @Override
091    public void service(ServiceManager manager) throws ServiceException
092    {
093        super.service(manager);
094        _sourceResolver = (SourceResolver) manager.lookup(SourceResolver.ROLE);
095        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
096        _contentWorkflowHelper = (ContentWorkflowHelper) manager.lookup(ContentWorkflowHelper.ROLE);
097    }
098
099    @Override
100    protected void _doExecute(JobExecutionContext context, ContainerProgressionTracker progressionTracker) throws Exception
101    {
102        File bookletDirectory = new File(AmetysHomeHelper.getAmetysHomeData(), EDUCATIONAL_BOOKLET_DIR_NAME);
103        FileUtils.forceMkdir(bookletDirectory);
104        
105        Map<String, Object> pdfParameters = new HashMap<>();
106        
107        JobDataMap jobDataMap = context.getJobDetail().getJobDataMap();
108        pdfParameters.put(PARAM_INCLUDE_SUBPROGRAMS, jobDataMap.get(Scheduler.PARAM_VALUES_PREFIX + PARAM_INCLUDE_SUBPROGRAMS));
109        
110        _generateProgramItemsEducationalBooklet(context, bookletDirectory, pdfParameters);
111    }
112
113    @Override
114    protected I18nizableText _getSuccessMailSubject(JobExecutionContext context)
115    {
116        EducationalBookletReport report = (EducationalBookletReport) context.get(_EDUCATIONAL_BOOKLET_REPORT);
117        return new I18nizableText("plugin.odf", _getMailSubjectBaseKey() + report.getCurrentStatus());
118    }
119    
120    @Override
121    protected I18nizableText _getErrorMailSubject(JobExecutionContext context)
122    {
123        return new I18nizableText("plugin.odf", _getMailSubjectBaseKey() + "ERROR");
124    }
125    
126    /**
127     * The base key for mail subjects.
128     * @return The prefix of an I18N key
129     */
130    protected String _getMailSubjectBaseKey()
131    {
132        return "PLUGINS_ODF_EDUCATIONAL_BOOKLET_PROGRAMITEM_MAIL_SUBJECT_";
133    }
134    
135    @Override
136    protected boolean _isMailBodyInHTML(JobExecutionContext context) throws Exception
137    {
138        return true;
139    }
140    
141    @Override
142    protected String _getSuccessMailBody(JobExecutionContext context, String language) throws IOException
143    {
144        EducationalBookletReport report = (EducationalBookletReport) context.get(_EDUCATIONAL_BOOKLET_REPORT);
145
146        List<Content> exportedProgramItems = report.getExportedProgramItems();
147        List<Content> programItemsInError = report.getProgramItemsInError();
148        
149        try
150        {
151            MailBodyBuilder bodyBuilder = StandardMailBodyHelper.newHTMLBody()
152                .withTitle(_getSuccessMailSubject(context))
153                .withLanguage(language);
154            
155            ReportStatus status = report.getCurrentStatus();
156            String i18nKey = _getMailBodyBaseKey();
157            
158            if (status == ReportStatus.SUCCESS || status == ReportStatus.PARTIAL)
159            {
160                String downloadLink = _getDownloadLink(context, report, exportedProgramItems);
161                
162                Map<String, I18nizableTextParameter> i18nParams = Map.of("link", new I18nizableText(downloadLink), "programItem", _getProgramItemsI18nText(exportedProgramItems));
163                bodyBuilder.addMessage(new I18nizableText("plugin.odf", i18nKey + "SUCCESS" + (exportedProgramItems.size() > 1 ? "_SEVERAL" : ""), i18nParams));
164                bodyBuilder.withLink(downloadLink, new I18nizableText("plugin.odf", i18nKey + "DOWNLOAD_LINK" + (exportedProgramItems.size() > 1 ? "_SEVERAL" : "")));
165            }
166            if (status == ReportStatus.PARTIAL || status == ReportStatus.ERROR)
167            {
168                Map<String, I18nizableTextParameter> i18nParams = Map.of("programItem", _getProgramItemsI18nText(programItemsInError));
169                bodyBuilder.addMessage(new I18nizableText("plugin.odf", i18nKey + "ERROR" + (exportedProgramItems.size() > 1 ? "_SEVERAL" : ""), i18nParams));
170            }
171            
172            return bodyBuilder.build();
173        }
174        catch (IOException e)
175        {
176            getLogger().error("Failed to build HTML email body for education booklet export result", e);
177            return null;
178        }
179    }
180    
181    /**
182     * Get the link to download PDF
183     * @param context the job execution context
184     * @param report the report
185     * @param exportedProgramItems the exported programs
186     * @return the download
187     * @throws IOException if failed to build the download uri
188     */
189    protected String _getDownloadLink(JobExecutionContext context, EducationalBookletReport report, List<Content> exportedProgramItems) throws IOException
190    {
191        String downloadLink = StringUtils.removeEndIgnoreCase(Config.getInstance().getValue("cms.url"), "/index.html");
192        
193        if (exportedProgramItems.size() > 1)
194        {
195            // Compress to a ZIP if there are several exported program items
196            DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
197            String zipKey = ZonedDateTime.now().format(formatter);
198            _generateEducationalBookletZip(context, report.getBookletDirectory(), exportedProgramItems, zipKey);
199            downloadLink += "/plugins/odf/download/educational-booklet-" + zipKey + "/educational-booklet.zip";
200        }
201        else
202        {
203            Content content = exportedProgramItems.get(0);
204            downloadLink += "/plugins/odf/download/" + content.getLanguage() + "/educational-booklet.pdf?programItemId=" + content.getId();
205        }
206        
207        return downloadLink;
208    }
209
210    @Override
211    protected String _getErrorMailBody(JobExecutionContext context, String language, Throwable throwable)
212    {
213        List<Content> programItems = Optional.of(context)
214            .map(JobExecutionContext::getJobDetail)
215            .map(JobDetail::getJobDataMap)
216            .map(map -> map.getString(Scheduler.PARAM_VALUES_PREFIX + "programItemIds"))
217            .map(ids -> ids.split(","))
218            .map(Stream::of)
219            .orElseGet(() -> Stream.empty())
220            .filter(StringUtils::isNotBlank)
221            .map(_resolver::<Content>resolveById)
222            .collect(Collectors.toList());
223
224        try
225        {
226            MailBodyBuilder bodyBuilder = StandardMailBodyHelper.newHTMLBody()
227                .withTitle(_getErrorMailSubject(context))
228                .withLanguage(language);
229            
230            Map<String, I18nizableTextParameter> i18nParams = Map.of("programItem", _getProgramItemsI18nText(programItems));
231            bodyBuilder.addMessage(new I18nizableText("plugin.odf", _getMailBodyBaseKey() + "ERROR" + (programItems.size() > 1 ? "_SEVERAL" : StringUtils.EMPTY), i18nParams));
232            
233            if (throwable != null)
234            {
235                String error = ExceptionUtils.getStackTrace(throwable);
236                bodyBuilder.withDetails(null, error, true);
237            }
238            
239            return bodyBuilder.build();
240        }
241        catch (IOException e)
242        {
243            getLogger().error("Failed to build HTML email body for education booklet export result", e);
244            return null;
245        }
246    }
247
248    /**
249     * The base key for mail bodies.
250     * @return The prefix of an I18N key
251     */
252    protected String _getMailBodyBaseKey()
253    {
254        return "PLUGINS_ODF_EDUCATIONAL_BOOKLET_PROGRAMITEM_MAIL_BODY_";
255    }
256    
257    /**
258     * Transform a list of program items in a readable list.
259     * @param programItems The program items to iterate on
260     * @return An {@link I18nizableText} representing the program items
261     */
262    protected I18nizableText _getProgramItemsI18nText(List<Content> programItems)
263    {
264        List<String> programItemsTitle = programItems.stream()
265            .map(c -> c.getTitle())
266            .collect(Collectors.toList());
267        
268        String readableTitles;
269        if (programItemsTitle.size() == 1)
270        {
271            readableTitles = programItemsTitle.get(0);
272        }
273        else
274        {
275            StringBuilder sb = new StringBuilder();
276            sb.append("<ul>");
277            programItemsTitle.stream().forEach(t -> sb.append("<li>").append(t).append("</li>"));
278            sb.append("</ul>");
279            
280            readableTitles = sb.toString();
281        }
282        
283        return new I18nizableText(readableTitles);
284    }
285    
286    /**
287     * Generate educational booklet for each program items
288     * @param context the context
289     * @param bookletDirectory the booklet directory
290     * @param pdfParameters the parameters to generate PDF
291     */
292    protected void _generateProgramItemsEducationalBooklet(JobExecutionContext context, File bookletDirectory, Map<String, Object> pdfParameters)
293    {
294        EducationalBookletReport report = new EducationalBookletReport(bookletDirectory);
295        
296        JobDataMap jobDataMap = context.getJobDetail().getJobDataMap();
297        String idsAsString = jobDataMap.getString(Scheduler.PARAM_VALUES_PREFIX + "programItemIds");
298        for (String programItemId : StringUtils.split(idsAsString, ","))
299        {
300            Content programItem = _resolver.resolveById(programItemId);
301            try
302            {
303                _generateProgramItemEducationalBookletPDF(bookletDirectory, programItem, pdfParameters);
304                report.addExportedProgramItem(programItem);
305            }
306            catch (IOException e)
307            {
308                getLogger().error("An error occurred while generating the educational booklet of program item '{}' ({}).", programItem.getTitle(), ((ProgramItem) programItem).getCode(), e);
309                report.addProgramItemIhError(programItem);
310            }
311        }
312        
313        context.put(_EDUCATIONAL_BOOKLET_REPORT, report);
314    }
315    
316    /**
317     * Generate the educational booklet for one program item
318     * @param bookletDirectory the booklet directory
319     * @param programItem the program item
320     * @param pdfParameters the parameters to generate PDF
321     * @throws IOException if an error occured with files
322     */
323    protected void _generateProgramItemEducationalBookletPDF(File bookletDirectory, Content programItem, Map<String, Object> pdfParameters) throws IOException
324    {
325        File programItemDir = new File(bookletDirectory, programItem.getName());
326        if (!programItemDir.exists())
327        {
328            programItemDir.mkdir();
329        }
330        
331        File langDir = new File (programItemDir, programItem.getLanguage());
332        if (!langDir.exists())
333        {
334            langDir.mkdir();
335        }
336        
337        Map<String, Object> localPdfParameters = new HashMap<>(pdfParameters);
338        localPdfParameters.put(PARAM_PROGRAM_ITEM_ID, programItem.getId());
339        _generateFile(
340            langDir,
341            "cocoon://_plugins/odf/booklet/" + programItem.getLanguage() + "/educational-booklet.pdf",
342            localPdfParameters,
343            "educational-booklet",
344            "pdf"
345        );
346    }
347    
348    /**
349     * Generate the zip with the educational booklet for each exported program items
350     * @param context the context
351     * @param bookletDirectory the booklet directory
352     * @param exportedProgramItems the exported program items
353     * @param zipKey the zip key
354     * @throws IOException if an error occured with files
355     */
356    protected void _generateEducationalBookletZip(JobExecutionContext context, File bookletDirectory, List<Content> exportedProgramItems, String zipKey) throws IOException
357    {
358        String ids = exportedProgramItems.stream()
359            .map(Content::getId)
360            .collect(Collectors.joining(","));
361        
362        _generateFile(
363            bookletDirectory,
364            "cocoon://_plugins/odf/booklet/educational-booklet.zip",
365            Map.of("programItemIds", ids),
366            "educational-booklet-" + zipKey,
367            "zip"
368        );
369    }
370
371    /**
372     * Generate a file from the uri
373     * @param bookletDirectory the booklet directory where the file are created
374     * @param uri the uri
375     * @param params the parameters of the uri
376     * @param name the name of the file
377     * @param extension the extension of the file
378     * @throws IOException if an error occured with files
379     */
380    protected void _generateFile(File bookletDirectory, String uri, Map<String, Object> params, String name, String extension) throws IOException
381    {
382        Request request = ContextHelper.getRequest(_context);
383        
384        SitemapSource source = null;
385        File pdfTmpFile = null;
386        try
387        {
388            // Set PDF parameters as request attributes
389            for (Entry<String, Object> param : params.entrySet())
390            {
391                request.setAttribute(param.getKey(), param.getValue());
392            }
393            // Resolve the export to the appropriate pdf url.
394            source = (SitemapSource) _sourceResolver.resolveURI(uri, null, params);
395            
396            // Save the pdf into a temporary file.
397            String tmpFile = name + ".tmp." + extension;
398            pdfTmpFile = new File(bookletDirectory, tmpFile);
399            
400            try (OutputStream pdfTmpOs = new FileOutputStream(pdfTmpFile); InputStream sourceIs = source.getInputStream())
401            {
402                SourceUtil.copy(sourceIs, pdfTmpOs);
403            }
404            
405            // If all went well until now, rename the temporary file
406            String fileName = name + "." + extension;
407            File bookletFile = new File(bookletDirectory, fileName);
408            if (bookletFile.exists())
409            {
410                bookletFile.delete();
411            }
412            
413            if (!pdfTmpFile.renameTo(bookletFile))
414            {
415                throw new IOException("Fail to rename " + tmpFile + " to " + fileName);
416            }
417        }
418        finally
419        {
420            if (pdfTmpFile != null)
421            {
422                FileUtils.deleteQuietly(pdfTmpFile);
423            }
424            
425            if (source != null)
426            {
427                _sourceResolver.release(source);
428            }
429            
430            for (Entry<String, Object> param : params.entrySet())
431            {
432                request.removeAttribute(param.getKey());
433            }
434        }
435    }
436    
437    /**
438     * Object to represent list of programs exported and list of programs with error after PDF generation
439     */
440    protected static class EducationalBookletReport
441    {
442        /**
443         * Status of export
444         */
445        public enum ReportStatus
446        {
447            
448            /** All program items have been exported successfully */
449            SUCCESS,
450            /** Program items have been exported partially */
451            PARTIAL,
452            /** Error during export */
453            ERROR
454            
455        }
456        
457        private File _bookletDirectory;
458        private List<Content> _exportedProgramItems;
459        private List<Content> _programItemsInError;
460        
461        /**
462         * The constructor
463         * @param bookletDirectory The booklet directory
464         */
465        public EducationalBookletReport(File bookletDirectory)
466        {
467            _bookletDirectory = bookletDirectory;
468            _exportedProgramItems = new ArrayList<>();
469            _programItemsInError = new ArrayList<>();
470        }
471        
472        /**
473         * Get the booklet directory
474         * @return the booklet directory
475         */
476        public File getBookletDirectory()
477        {
478            return _bookletDirectory;
479        }
480        
481        /**
482         * Get the list of exported program items
483         * @return the list of exported program items
484         */
485        public List<Content> getExportedProgramItems()
486        {
487            return _exportedProgramItems;
488        }
489        
490        /**
491         * Add a content as exported
492         * @param content the content to add
493         */
494        public void addExportedProgramItem(Content content)
495        {
496            _exportedProgramItems.add(content);
497        }
498        
499        /**
500         * Set the exported program items
501         * @param programItems the list of exported program items
502         */
503        public void setExportedProgramItems(List<Content> programItems)
504        {
505            _exportedProgramItems = programItems;
506        }
507        
508        /**
509         * Get the program items in error
510         * @return the list of program items in error
511         */
512        public List<Content> getProgramItemsInError()
513        {
514            return _programItemsInError;
515        }
516        
517        /**
518         * Add program item as error
519         * @param programItem the program item to add
520         */
521        public void addProgramItemIhError(Content programItem)
522        {
523            _programItemsInError.add(programItem);
524        }
525        
526        /**
527         * The current status of the educational booklet generation.
528         * @return The report status
529         */
530        public ReportStatus getCurrentStatus()
531        {
532            if (_programItemsInError.isEmpty())
533            {
534                return ReportStatus.SUCCESS;
535            }
536            
537            if (_exportedProgramItems.isEmpty())
538            {
539                return ReportStatus.ERROR;
540            }
541            
542            return ReportStatus.PARTIAL;
543        }
544    }
545}