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.Optional;
030import java.util.stream.Collectors;
031import java.util.stream.Stream;
032
033import org.apache.avalon.framework.service.ServiceException;
034import org.apache.avalon.framework.service.ServiceManager;
035import org.apache.cocoon.components.source.impl.SitemapSource;
036import org.apache.commons.io.FileUtils;
037import org.apache.commons.lang.StringUtils;
038import org.apache.commons.lang3.exception.ExceptionUtils;
039import org.apache.excalibur.source.SourceResolver;
040import org.apache.excalibur.source.SourceUtil;
041import org.quartz.JobDataMap;
042import org.quartz.JobDetail;
043import org.quartz.JobExecutionContext;
044
045import org.ametys.cms.schedule.AbstractSendingMailSchedulable;
046import org.ametys.cms.workflow.ContentWorkflowHelper;
047import org.ametys.core.schedule.Schedulable;
048import org.ametys.odf.program.SubProgram;
049import org.ametys.plugins.core.schedule.Scheduler;
050import org.ametys.plugins.repository.AmetysObjectResolver;
051import org.ametys.runtime.config.Config;
052import org.ametys.runtime.i18n.I18nizableText;
053import org.ametys.runtime.i18n.I18nizableTextParameter;
054import org.ametys.runtime.util.AmetysHomeHelper;
055
056/**
057 * {@link Schedulable} for educational booklet.
058 */
059public class EducationalBookletSchedulable extends AbstractSendingMailSchedulable
060{
061    /** The directory under ametys home data directory for educational booklet */
062    public static final String EDUCATIONAL_BOOKLET_DIR_NAME = "odf/booklet";
063    
064    /** Map key where the report is stored */
065    protected static final String _EDUCATIONAL_BOOKLET_REPORT = "educationalBookletReport";
066
067    /** The avalon source resolver. */
068    protected SourceResolver _sourceResolver;
069    
070    /** The ametys object resolver */
071    protected AmetysObjectResolver _resolver;
072    
073    /** The content workflow helper */
074    protected ContentWorkflowHelper _contentWorkflowHelper;
075    
076    @Override
077    public void service(ServiceManager manager) throws ServiceException
078    {
079        super.service(manager);
080        _sourceResolver = (SourceResolver) manager.lookup(SourceResolver.ROLE);
081        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
082        _contentWorkflowHelper = (ContentWorkflowHelper) manager.lookup(ContentWorkflowHelper.ROLE);
083    }
084
085    @Override
086    protected void _doExecute(JobExecutionContext context) throws Exception
087    {
088        File bookletDirectory = new File(AmetysHomeHelper.getAmetysHomeData(), EDUCATIONAL_BOOKLET_DIR_NAME);
089        FileUtils.forceMkdir(bookletDirectory);
090        
091        Map<String, String> pdfParameters = new HashMap<>();
092        _generateSubProgramsEducationalBooklet(context, bookletDirectory, pdfParameters);
093    }
094
095    @Override
096    protected I18nizableText _getSuccessMailSubject(JobExecutionContext context)
097    {
098        EducationalBookletReport report = (EducationalBookletReport) context.get(_EDUCATIONAL_BOOKLET_REPORT);
099        return new I18nizableText("plugin.odf", _getMailSubjectBaseKey() + report.getCurrentStatus());
100    }
101    
102    @Override
103    protected I18nizableText _getErrorMailSubject(JobExecutionContext context)
104    {
105        return new I18nizableText("plugin.odf", _getMailSubjectBaseKey() + "FAILURE");
106    }
107    
108    /**
109     * The base key for mail subjects.
110     * @return The prefix of an I18N key
111     */
112    protected String _getMailSubjectBaseKey()
113    {
114        return "PLUGINS_ODF_EDUCATIONAL_BOOKLET_SUBPROGRAM_MAIL_SUBJECT_";
115    }
116    
117    @Override
118    protected I18nizableText _getSuccessMailBody(JobExecutionContext context) throws IOException
119    {
120        EducationalBookletReport report = (EducationalBookletReport) context.get(_EDUCATIONAL_BOOKLET_REPORT);
121
122        Map<String, I18nizableTextParameter> params = new HashMap<>();
123        
124        List<SubProgram> exportSubPrograms = report.getExportSubPrograms();
125        List<SubProgram> subProgramsWithError = report.getSubProgramsWithError();
126
127        String status = report.getCurrentStatus();
128        String i18nKey = _getMailBodyBaseKey();
129        switch (status)
130        {
131            case "FAILURE":
132                i18nKey += "FAILURE";
133                if (subProgramsWithError.size() > 1)
134                {
135                    i18nKey += "_SEVERAL";
136                }
137                params.put("subprogram", _getSubProgramListAsI18nText(subProgramsWithError));
138                break;
139            case "SUCCESS_WITH_ERRORS":
140                params.put("error", new I18nizableText("plugin.odf", _getMailBodyBaseKey() + "FAILURE", Map.of("subprogram", _getSubProgramListAsI18nText(subProgramsWithError))));
141                // fallthrough
142            case "SUCCESS":
143                i18nKey += "SUCCESS";
144                String downloadLink = StringUtils.removeEndIgnoreCase(Config.getInstance().getValue("cms.url"), "/index.html");
145                if (exportSubPrograms.size() > 1)
146                {
147                    i18nKey += "_SEVERAL";
148                    // Compress to a ZIP if there are several exported subprograms
149                    DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
150                    String zipKey = ZonedDateTime.now().format(formatter);
151                    _generateEducationalBookletZip(context, report.getBookletDirectory(), exportSubPrograms, zipKey);
152                    downloadLink += "/plugins/odf/download/educational-booklet-" + zipKey + "/educational-booklet.zip";
153                }
154                else
155                {
156                    SubProgram subProgram = exportSubPrograms.get(0); 
157                    downloadLink += "/plugins/odf/download/" + subProgram.getLanguage() + "/educational-booklet.pdf?subProgramId=" + subProgram.getId();
158                }
159                params.put("link", new I18nizableText(downloadLink));
160                params.put("subprogram", _getSubProgramListAsI18nText(exportSubPrograms));
161                break;
162            default:
163                // Shouldn't happen
164                break;
165        }
166        
167        return new I18nizableText("plugin.odf", i18nKey, params);
168    }
169
170    @Override
171    protected I18nizableText _getErrorMailBody(JobExecutionContext context, Throwable throwable)
172    {
173        List<SubProgram> subPrograms = Optional.of(context)
174            .map(JobExecutionContext::getJobDetail)
175            .map(JobDetail::getJobDataMap)
176            .map(map -> map.getString(Scheduler.PARAM_VALUES_PREFIX + "subProgramIds"))
177            .map(ids -> ids.split(","))
178            .map(Stream::of)
179            .orElseGet(() -> Stream.empty())
180            .filter(StringUtils::isNotBlank)
181            .map(_resolver::<SubProgram>resolveById)
182            .collect(Collectors.toList());
183
184        Map<String, I18nizableTextParameter> params = new HashMap<>();
185        
186        params.put("subprogram", _getSubProgramListAsI18nText(subPrograms));
187        
188        if (throwable != null)
189        {
190            String error = ExceptionUtils.getStackTrace(throwable);
191            params.put("error", new I18nizableText(error));
192        }
193        
194        return new I18nizableText("plugin.odf", _getMailBodyBaseKey() + "FAILURE" + (subPrograms.size() > 1 ? "_SEVERAL" : StringUtils.EMPTY), params);
195    }
196
197    /**
198     * The base key for mail bodies.
199     * @return The prefix of an I18N key
200     */
201    protected String _getMailBodyBaseKey()
202    {
203        return "PLUGINS_ODF_EDUCATIONAL_BOOKLET_SUBPROGRAM_MAIL_BODY_";
204    }
205    
206    /**
207     * Transform a list of subprogram in a readable list.
208     * @param subPrograms The subprograms to iterate on
209     * @return An {@link I18nizableText} representing the list of subprograms or only a subprogram title
210     */
211    protected I18nizableText _getSubProgramListAsI18nText(List<SubProgram> subPrograms)
212    {
213        if (subPrograms.size() > 1)
214        {
215            StringBuilder subProgramSB = new StringBuilder();
216            for (SubProgram subProgram : subPrograms)
217            {
218                subProgramSB.append("\n- ");
219                subProgramSB.append(subProgram.getTitle());
220            }
221            return new I18nizableText(subProgramSB.toString());
222        }
223        
224        return new I18nizableText(subPrograms.get(0).getTitle());
225    }
226    
227    /**
228     * Generate educational booklet for each subProgram
229     * @param context the context
230     * @param bookletDirectory the booklet directory
231     * @param pdfParameters the parameters to generate PDF
232     */
233    protected void _generateSubProgramsEducationalBooklet(JobExecutionContext context, File bookletDirectory, Map<String, String> pdfParameters)
234    {
235        EducationalBookletReport report = new EducationalBookletReport(bookletDirectory);
236        
237        JobDataMap jobDataMap = context.getJobDetail().getJobDataMap();
238        String subProgramIdsAsString = jobDataMap.getString(Scheduler.PARAM_VALUES_PREFIX + "subProgramIds");
239        for (String subProgramId : StringUtils.split(subProgramIdsAsString, ","))
240        {
241            SubProgram subProgram = _resolver.resolveById(subProgramId);
242            try
243            {
244                _generateSubProgramEducationalBookletPDF(bookletDirectory, subProgram, pdfParameters);
245                report.addExportSubProgram(subProgram);
246            }
247            catch (IOException e)
248            {
249                getLogger().error("An error occurred while generating the educational booklet of subprogram '{}' ({}).", subProgram.getTitle(), subProgram.getCode(), e);
250                report.addSubProgramWithError(subProgram);
251            }
252        }
253        
254        context.put(_EDUCATIONAL_BOOKLET_REPORT, report);
255    }
256    
257    /**
258     * Generate the educational booklet for one subProgram
259     * @param bookletDirectory the booklet directory
260     * @param subProgram the subProgram
261     * @param pdfParameters the parameters to generate PDF
262     * @throws IOException if an error occured with files
263     */
264    protected void _generateSubProgramEducationalBookletPDF(File bookletDirectory, SubProgram subProgram, Map<String, String> pdfParameters) throws IOException
265    {
266        File subProgramDir = new File(bookletDirectory, subProgram.getName());
267        if (!subProgramDir.exists())
268        {
269            subProgramDir.mkdir();
270        }
271        
272        File langDir = new File (subProgramDir, subProgram.getLanguage());
273        if (!langDir.exists())
274        {
275            langDir.mkdir();
276        }
277        
278        Map<String, String> localPdfParameters = new HashMap<>(pdfParameters);
279        localPdfParameters.put("subProgramId", subProgram.getId());
280        _generateFile(
281            langDir,
282            "cocoon://_plugins/odf/booklet/" + subProgram.getLanguage() + "/educational-booklet.pdf",
283            localPdfParameters,
284            "educational-booklet",
285            "pdf"
286        );
287    }
288    
289    /**
290     * Generate the zip with the educational booklet for each export subProgram
291     * @param context the context
292     * @param bookletDirectory the booklet directory
293     * @param exportSubPrograms the export subPrograms
294     * @param zipKey the zip key
295     * @throws IOException if an error occured with files
296     */
297    protected void _generateEducationalBookletZip(JobExecutionContext context, File bookletDirectory, List<SubProgram> exportSubPrograms, String zipKey) throws IOException
298    {
299        String exportSubProgramIds = exportSubPrograms.stream()
300            .map(SubProgram::getId)
301            .collect(Collectors.joining(","));
302        
303        _generateFile(
304            bookletDirectory,
305            "cocoon://_plugins/odf/booklet/educational-booklet.zip",
306            Map.of("subProgramIds", exportSubProgramIds),
307            "educational-booklet-" + zipKey,
308            "zip"
309        );
310    }
311
312    /**
313     * Generate a file from the uri
314     * @param bookletDirectory the booklet directory where the file are created
315     * @param uri the uri
316     * @param parameters the parameters of the uri
317     * @param name the name of the file
318     * @param extension the extension of the file
319     * @throws IOException if an error occured with files
320     */
321    protected void _generateFile(File bookletDirectory, String uri, Map<String, String> parameters, String name, String extension) throws IOException
322    {
323        SitemapSource source = null;
324        File pdfTmpFile = null;
325        try
326        {
327            // Resolve the export to the appropriate pdf url.
328            source = (SitemapSource) _sourceResolver.resolveURI(uri, null, parameters);
329            
330            // Save the pdf into a temporary file.
331            String tmpFile = name + ".tmp." + extension;
332            pdfTmpFile = new File(bookletDirectory, tmpFile);
333            
334            try (OutputStream pdfTmpOs = new FileOutputStream(pdfTmpFile); InputStream sourceIs = source.getInputStream())
335            {
336                SourceUtil.copy(sourceIs, pdfTmpOs);
337            }
338            
339            // If all went well until now, rename the temporary file 
340            String fileName = name + "." + extension;
341            File bookletFile = new File(bookletDirectory, fileName);
342            if (bookletFile.exists())
343            {
344                bookletFile.delete();
345            }
346            
347            if (!pdfTmpFile.renameTo(bookletFile))
348            {
349                throw new IOException("Fail to rename " + tmpFile + " to " + fileName);
350            }
351        }
352        finally
353        {
354            if (pdfTmpFile != null)
355            {
356                FileUtils.deleteQuietly(pdfTmpFile);
357            }
358            
359            if (source != null)
360            {
361                _sourceResolver.release(source);
362            }
363        }
364    }
365    
366    /**
367     * Object to represent list of programs exported and list of programs with error after PDF generation
368     */
369    protected static class EducationalBookletReport
370    {
371        private File _bookletDirectory;
372        private List<SubProgram> _exportSubPrograms;
373        private List<SubProgram> _subProgramsWithError;
374        
375        /**
376         * The constructor
377         * @param bookletDirectory The booklet directory
378         */
379        public EducationalBookletReport(File bookletDirectory)
380        {
381            _bookletDirectory = bookletDirectory;
382            _exportSubPrograms = new ArrayList<>();
383            _subProgramsWithError = new ArrayList<>();
384        }
385        
386        /**
387         * Get the booklet directory
388         * @return the booklet directory
389         */
390        public File getBookletDirectory()
391        {
392            return _bookletDirectory;
393        }
394        
395        /**
396         * Get export subPrograms 
397         * @return the list of export subPrograms
398         */
399        public List<SubProgram> getExportSubPrograms()
400        {
401            return _exportSubPrograms;
402        }
403        
404        /**
405         * Add subProgram to export subPrograms
406         * @param subProgram the subProgram to add
407         */
408        public void addExportSubProgram(SubProgram subProgram)
409        {
410            _exportSubPrograms.add(subProgram);
411        }
412        
413        /**
414         * Set the export subPrograms
415         * @param subPrograms the list of export subPrograms
416         */
417        public void setExportSubProgram(List<SubProgram> subPrograms)
418        {
419            _exportSubPrograms = subPrograms;
420        }
421        
422        /**
423         * Get subPrograms with error
424         * @return the list of subPrograms with error
425         */
426        public List<SubProgram> getSubProgramsWithError()
427        {
428            return _subProgramsWithError;
429        }
430        
431        /**
432         * Add subProgram to subPrograms with error
433         * @param subProgram the subProgram to add
434         */
435        public void addSubProgramWithError(SubProgram subProgram)
436        {
437            _subProgramsWithError.add(subProgram);
438        }
439        
440        /**
441         * The current status of the educational booklet generation.
442         *  - FAILURE: No subprograms with errors
443         *  - SUCCESS: All subprograms are successfully generated
444         *  - SUCCESS_WITH_ERRORS: Some subprograms are successfully generated but not all of them
445         * @return A {@link String} representing the status
446         */
447        public String getCurrentStatus()
448        {
449            if (_subProgramsWithError.isEmpty())
450            {
451                return "SUCCESS";
452            }
453            
454            if (_exportSubPrograms.isEmpty())
455            {
456                return "FAILURE";
457            }
458            
459            return "SUCCESS_WITH_ERRORS";
460        }
461    }
462}