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