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) 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            
154            ReportStatus status = report.getCurrentStatus();
155            String i18nKey = _getMailBodyBaseKey();
156            
157            if (status == ReportStatus.SUCCESS || status == ReportStatus.PARTIAL)
158            {
159                String downloadLink = _getDownloadLink(context, report, exportedProgramItems);
160                
161                Map<String, I18nizableTextParameter> i18nParams = Map.of("link", new I18nizableText(downloadLink), "programItem", _getProgramItemsI18nText(exportedProgramItems));
162                bodyBuilder.addMessage(new I18nizableText("plugin.odf", i18nKey + "SUCCESS" + (exportedProgramItems.size() > 1 ? "_SEVERAL" : ""), i18nParams));
163                bodyBuilder.withLink(downloadLink, new I18nizableText("plugin.odf", i18nKey + "DOWNLOAD_LINK" + (exportedProgramItems.size() > 1 ? "_SEVERAL" : "")));
164            }
165            if (status == ReportStatus.PARTIAL || status == ReportStatus.ERROR)
166            {
167                Map<String, I18nizableTextParameter> i18nParams = Map.of("programItem", _getProgramItemsI18nText(programItemsInError));
168                bodyBuilder.addMessage(new I18nizableText("plugin.odf", i18nKey + "ERROR" + (exportedProgramItems.size() > 1 ? "_SEVERAL" : ""), i18nParams));
169            }
170            
171            return bodyBuilder.build();
172        }
173        catch (IOException e)
174        {
175            getLogger().error("Failed to build HTML email body for education booklet export result", e);
176            return null;
177        }
178    }
179    
180    /**
181     * Get the link to download PDF
182     * @param context the job execution context
183     * @param report the report
184     * @param exportedProgramItems the exported programs
185     * @return the download
186     * @throws IOException if failed to build the download uri
187     */
188    protected String _getDownloadLink(JobExecutionContext context, EducationalBookletReport report, List<Content> exportedProgramItems) throws IOException
189    {
190        String downloadLink = StringUtils.removeEndIgnoreCase(Config.getInstance().getValue("cms.url"), "/index.html");
191        
192        if (exportedProgramItems.size() > 1)
193        {
194            // Compress to a ZIP if there are several exported program items
195            DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
196            String zipKey = ZonedDateTime.now().format(formatter);
197            _generateEducationalBookletZip(context, report.getBookletDirectory(), exportedProgramItems, zipKey);
198            downloadLink += "/plugins/odf/download/educational-booklet-" + zipKey + "/educational-booklet.zip";
199        }
200        else
201        {
202            Content content = exportedProgramItems.get(0); 
203            downloadLink += "/plugins/odf/download/" + content.getLanguage() + "/educational-booklet.pdf?programItemId=" + content.getId();
204        }
205        
206        return downloadLink;
207    }
208
209    @Override
210    protected String _getErrorMailBody(JobExecutionContext context, Throwable throwable)
211    {
212        List<Content> programItems = Optional.of(context)
213            .map(JobExecutionContext::getJobDetail)
214            .map(JobDetail::getJobDataMap)
215            .map(map -> map.getString(Scheduler.PARAM_VALUES_PREFIX + "programItemIds"))
216            .map(ids -> ids.split(","))
217            .map(Stream::of)
218            .orElseGet(() -> Stream.empty())
219            .filter(StringUtils::isNotBlank)
220            .map(_resolver::<Content>resolveById)
221            .collect(Collectors.toList());
222
223        try
224        {
225            MailBodyBuilder bodyBuilder = StandardMailBodyHelper.newHTMLBody()
226                .withTitle(_getErrorMailSubject(context));
227            
228            Map<String, I18nizableTextParameter> i18nParams = Map.of("programItem", _getProgramItemsI18nText(programItems));
229            bodyBuilder.addMessage(new I18nizableText("plugin.odf", _getMailBodyBaseKey() + "ERROR" + (programItems.size() > 1 ? "_SEVERAL" : StringUtils.EMPTY), i18nParams));
230            
231            if (throwable != null)
232            {
233                String error = ExceptionUtils.getStackTrace(throwable);
234                bodyBuilder.withDetails(null, error, true);
235            }
236            
237            return bodyBuilder.build();
238        }
239        catch (IOException e)
240        {
241            getLogger().error("Failed to build HTML email body for education booklet export result", e);
242            return null;
243        }
244    }
245
246    /**
247     * The base key for mail bodies.
248     * @return The prefix of an I18N key
249     */
250    protected String _getMailBodyBaseKey()
251    {
252        return "PLUGINS_ODF_EDUCATIONAL_BOOKLET_PROGRAMITEM_MAIL_BODY_";
253    }
254    
255    /**
256     * Transform a list of program items in a readable list.
257     * @param programItems The program items to iterate on
258     * @return An {@link I18nizableText} representing the program items
259     */
260    protected I18nizableText _getProgramItemsI18nText(List<Content> programItems)
261    {
262        List<String> programItemsTitle = programItems.stream()
263            .map(c -> c.getTitle())
264            .collect(Collectors.toList());
265        
266        String readableTitles;
267        if (programItemsTitle.size() == 1)
268        {
269            readableTitles = programItemsTitle.get(0);
270        }
271        else
272        {
273            StringBuilder sb = new StringBuilder();
274            sb.append("<ul>");
275            programItemsTitle.stream().forEach(t -> sb.append("<li>").append(t).append("</li>"));
276            sb.append("</ul>");
277            
278            readableTitles = sb.toString();
279        }
280        
281        return new I18nizableText(readableTitles);
282    }
283    
284    /**
285     * Generate educational booklet for each program items
286     * @param context the context
287     * @param bookletDirectory the booklet directory
288     * @param pdfParameters the parameters to generate PDF
289     */
290    protected void _generateProgramItemsEducationalBooklet(JobExecutionContext context, File bookletDirectory, Map<String, Object> pdfParameters)
291    {
292        EducationalBookletReport report = new EducationalBookletReport(bookletDirectory);
293        
294        JobDataMap jobDataMap = context.getJobDetail().getJobDataMap();
295        String idsAsString = jobDataMap.getString(Scheduler.PARAM_VALUES_PREFIX + "programItemIds");
296        for (String programItemId : StringUtils.split(idsAsString, ","))
297        {
298            Content programItem = _resolver.resolveById(programItemId);
299            try
300            {
301                _generateProgramItemEducationalBookletPDF(bookletDirectory, programItem, pdfParameters);
302                report.addExportedProgramItem(programItem);
303            }
304            catch (IOException e)
305            {
306                getLogger().error("An error occurred while generating the educational booklet of program item '{}' ({}).", programItem.getTitle(), ((ProgramItem) programItem).getCode(), e);
307                report.addProgramItemIhError(programItem);
308            }
309        }
310        
311        context.put(_EDUCATIONAL_BOOKLET_REPORT, report);
312    }
313    
314    /**
315     * Generate the educational booklet for one program item
316     * @param bookletDirectory the booklet directory
317     * @param programItem the program item
318     * @param pdfParameters the parameters to generate PDF
319     * @throws IOException if an error occured with files
320     */
321    protected void _generateProgramItemEducationalBookletPDF(File bookletDirectory, Content programItem, Map<String, Object> pdfParameters) throws IOException
322    {
323        File programItemDir = new File(bookletDirectory, programItem.getName());
324        if (!programItemDir.exists())
325        {
326            programItemDir.mkdir();
327        }
328        
329        File langDir = new File (programItemDir, programItem.getLanguage());
330        if (!langDir.exists())
331        {
332            langDir.mkdir();
333        }
334        
335        Map<String, Object> localPdfParameters = new HashMap<>(pdfParameters);
336        localPdfParameters.put(PARAM_PROGRAM_ITEM_ID, programItem.getId());
337        _generateFile(
338            langDir,
339            "cocoon://_plugins/odf/booklet/" + programItem.getLanguage() + "/educational-booklet.pdf",
340            localPdfParameters,
341            "educational-booklet",
342            "pdf"
343        );
344    }
345    
346    /**
347     * Generate the zip with the educational booklet for each exported program items
348     * @param context the context
349     * @param bookletDirectory the booklet directory
350     * @param exportedProgramItems the exported program items
351     * @param zipKey the zip key
352     * @throws IOException if an error occured with files
353     */
354    protected void _generateEducationalBookletZip(JobExecutionContext context, File bookletDirectory, List<Content> exportedProgramItems, String zipKey) throws IOException
355    {
356        String ids = exportedProgramItems.stream()
357            .map(Content::getId)
358            .collect(Collectors.joining(","));
359        
360        _generateFile(
361            bookletDirectory,
362            "cocoon://_plugins/odf/booklet/educational-booklet.zip",
363            Map.of("programItemIds", ids),
364            "educational-booklet-" + zipKey,
365            "zip"
366        );
367    }
368
369    /**
370     * Generate a file from the uri
371     * @param bookletDirectory the booklet directory where the file are created
372     * @param uri the uri
373     * @param params the parameters of the uri
374     * @param name the name of the file
375     * @param extension the extension of the file
376     * @throws IOException if an error occured with files
377     */
378    protected void _generateFile(File bookletDirectory, String uri, Map<String, Object> params, String name, String extension) throws IOException
379    {
380        Request request = ContextHelper.getRequest(_context);
381        
382        SitemapSource source = null;
383        File pdfTmpFile = null;
384        try
385        {
386            // Set PDF parameters as request attributes
387            for (Entry<String, Object> param : params.entrySet())
388            {
389                request.setAttribute(param.getKey(), param.getValue());
390            }
391            // Resolve the export to the appropriate pdf url.
392            source = (SitemapSource) _sourceResolver.resolveURI(uri, null, params);
393            
394            // Save the pdf into a temporary file.
395            String tmpFile = name + ".tmp." + extension;
396            pdfTmpFile = new File(bookletDirectory, tmpFile);
397            
398            try (OutputStream pdfTmpOs = new FileOutputStream(pdfTmpFile); InputStream sourceIs = source.getInputStream())
399            {
400                SourceUtil.copy(sourceIs, pdfTmpOs);
401            }
402            
403            // If all went well until now, rename the temporary file 
404            String fileName = name + "." + extension;
405            File bookletFile = new File(bookletDirectory, fileName);
406            if (bookletFile.exists())
407            {
408                bookletFile.delete();
409            }
410            
411            if (!pdfTmpFile.renameTo(bookletFile))
412            {
413                throw new IOException("Fail to rename " + tmpFile + " to " + fileName);
414            }
415        }
416        finally
417        {
418            if (pdfTmpFile != null)
419            {
420                FileUtils.deleteQuietly(pdfTmpFile);
421            }
422            
423            if (source != null)
424            {
425                _sourceResolver.release(source);
426            }
427            
428            for (Entry<String, Object> param : params.entrySet())
429            {
430                request.removeAttribute(param.getKey());
431            }
432        }
433    }
434    
435    /**
436     * Object to represent list of programs exported and list of programs with error after PDF generation
437     */
438    protected static class EducationalBookletReport
439    {
440        /**
441         * Status of export
442         */
443        public enum ReportStatus
444        {
445            
446            /** All program items have been exported successfully */
447            SUCCESS,
448            /** Program items have been exported partially */
449            PARTIAL,
450            /** Error during export */
451            ERROR
452            
453        }
454        
455        private File _bookletDirectory;
456        private List<Content> _exportedProgramItems;
457        private List<Content> _programItemsInError;
458        
459        /**
460         * The constructor
461         * @param bookletDirectory The booklet directory
462         */
463        public EducationalBookletReport(File bookletDirectory)
464        {
465            _bookletDirectory = bookletDirectory;
466            _exportedProgramItems = new ArrayList<>();
467            _programItemsInError = new ArrayList<>();
468        }
469        
470        /**
471         * Get the booklet directory
472         * @return the booklet directory
473         */
474        public File getBookletDirectory()
475        {
476            return _bookletDirectory;
477        }
478        
479        /**
480         * Get the list of exported program items
481         * @return the list of exported program items
482         */
483        public List<Content> getExportedProgramItems()
484        {
485            return _exportedProgramItems;
486        }
487        
488        /**
489         * Add a content as exported
490         * @param content the content to add
491         */
492        public void addExportedProgramItem(Content content)
493        {
494            _exportedProgramItems.add(content);
495        }
496        
497        /**
498         * Set the exported program items
499         * @param programItems the list of exported program items
500         */
501        public void setExportedProgramItems(List<Content> programItems)
502        {
503            _exportedProgramItems = programItems;
504        }
505        
506        /**
507         * Get the program items in error
508         * @return the list of program items in error
509         */
510        public List<Content> getProgramItemsInError()
511        {
512            return _programItemsInError;
513        }
514        
515        /**
516         * Add program item as error
517         * @param programItem the program item to add
518         */
519        public void addProgramItemIhError(Content programItem)
520        {
521            _programItemsInError.add(programItem);
522        }
523        
524        /**
525         * The current status of the educational booklet generation.
526         * @return The report status
527         */
528        public ReportStatus getCurrentStatus()
529        {
530            if (_programItemsInError.isEmpty())
531            {
532                return ReportStatus.SUCCESS;
533            }
534            
535            if (_exportedProgramItems.isEmpty())
536            {
537                return ReportStatus.ERROR;
538            }
539            
540            return ReportStatus.PARTIAL;
541        }
542    }
543}